diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index 64fa997d4..b6582ad7b 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -1,23 +1,25 @@ name: CS on: - # Run on all relevant pushes (except to main) and on all relevant pull requests. + # Run on all relevant pushes and pull requests. push: paths: - '**.php' + - '.github/workflows/**' + - 'bin/verify-workflow-ci-gates.js' - 'composer.json' - 'composer.lock' - '.phpcs.xml.dist' - 'phpcs.xml.dist' - - '.github/workflows/cs.yml' pull_request: paths: - '**.php' + - '.github/workflows/**' + - 'bin/verify-workflow-ci-gates.js' - 'composer.json' - 'composer.lock' - '.phpcs.xml.dist' - 'phpcs.xml.dist' - - '.github/workflows/cs.yml' # Allow manually triggering the workflow. workflow_dispatch: @@ -55,6 +57,11 @@ jobs: - name: Validate Composer installation run: composer validate --no-check-all + - name: Verify workflow CI gates + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: composer run verify-workflow-ci-gates + # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-composer-dependencies - name: Install Composer dependencies diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 7be965a7c..3c12f356f 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - trunk pull_request: # Disable permissions for all available scopes by default. @@ -34,8 +35,12 @@ jobs: run: npx playwright install --with-deps - name: Run end-to-end tests + env: + WP_TEST_DB_BACKEND: sqlite run: composer run test-e2e - name: Stop Docker containers if: always() + env: + WP_TEST_DB_BACKEND: sqlite run: composer run wp-test-clean diff --git a/.github/workflows/lexer-benchmark.yml b/.github/workflows/lexer-benchmark.yml index 9c41005f0..584d0a835 100644 --- a/.github/workflows/lexer-benchmark.yml +++ b/.github/workflows/lexer-benchmark.yml @@ -24,7 +24,8 @@ jobs: timeout-minutes: 15 permissions: contents: read # Required to clone the repo. - pull-requests: write # Required to post/update the result comment. + issues: write # Required to post/update the result comment. + pull-requests: write # Required to inspect pull request metadata. steps: - name: Checkout repository @@ -84,8 +85,10 @@ jobs: echo '```' } > "$RUNNER_TEMP/comment.md" echo "COMMENT_FILE=$RUNNER_TEMP/comment.md" >> "$GITHUB_ENV" + cat "$RUNNER_TEMP/comment.md" >> "$GITHUB_STEP_SUMMARY" - name: Post or update the PR comment + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 5126e2ea3..2d6be3d27 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -3,6 +3,7 @@ name: PHPUnit Tests on: push: branches: + - main - trunk paths: - '.github/workflows/phpunit-tests.yml' @@ -30,9 +31,9 @@ permissions: {} jobs: test: # The pure-PHP parser is exercised across the full PHP/SQLite range; the - # native Rust parser extension is exercised on PHP 8.0+ (its minimum). Both - # run the same mysql-on-sqlite suite, just with a different parser engine. - name: PHP ${{ matrix.php }}${{ matrix.extension && ' + ext-wp-mysql-parser' || '' }} / SQLite ${{ matrix.sqlite }} + # native Rust parser extension is exercised on PHP 8.0+ (its minimum). + # PostgreSQL-specific tests run in one bounded adapter lane. + name: PHP ${{ matrix.php }}${{ matrix.extension && ' + ext-wp-mysql-parser' || '' }} / SQLite ${{ matrix.sqlite }} / ${{ matrix.testsuite }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -43,28 +44,29 @@ 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 } # 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 uses: actions/checkout@v4 - name: Set up SQLite + if: ${{ matrix.testsuite == 'default' }} run: | VERSION='${{ matrix.sqlite }}' if [ "$VERSION" = 'latest' ]; then @@ -108,9 +110,11 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none + extensions: pdo_sqlite tools: phpunit-polyfills - name: Verify SQLite version in PHP + if: ${{ matrix.testsuite == 'default' }} run: | EXPECTED='${{ matrix.sqlite }}' if [ "$EXPECTED" = 'latest' ]; then @@ -179,10 +183,58 @@ 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 + + postgresql-test: + name: PHP 8.3 / PostgreSQL / postgresql + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # Required to clone the repo. + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: wordpress_develop + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U root -d wordpress_develop" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + extensions: pdo_pgsql + tools: phpunit-polyfills + + - name: Install Composer dependencies (mysql-on-sqlite) + uses: ramsey/composer-install@v3 + with: + working-directory: packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Run PostgreSQL PHPUnit suite + env: + PGSQL_TEST_DSN: pgsql:host=127.0.0.1;port=5432;dbname=wordpress_develop + PGSQL_TEST_USER: root + PGSQL_TEST_PASSWORD: password + run: composer run test-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..4389cadb9 100644 --- a/.github/workflows/wp-tests-end-to-end.yml +++ b/.github/workflows/wp-tests-end-to-end.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - trunk pull_request: # Disable permissions for all available scopes by default. @@ -12,11 +13,17 @@ permissions: {} jobs: test: - name: WordPress End-to-end Tests + name: WordPress End-to-end Tests / ${{ matrix.backend }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: read # Required to clone the repo. + strategy: + fail-fast: false + matrix: + backend: + - sqlite + - postgresql steps: - name: Checkout repository @@ -34,8 +41,34 @@ jobs: run: npx playwright install --with-deps - name: Run WordPress end-to-end tests + env: + WP_TEST_DB_BACKEND: ${{ matrix.backend }} + WP_POSTGRESQL_E2E_REQUEST_DIAGNOSTICS: ${{ matrix.backend == 'postgresql' && '1' || '0' }} run: composer run wp-test-e2e + - name: Dump WordPress Docker logs + if: failure() + env: + WP_TEST_DB_BACKEND: ${{ matrix.backend }} + LOCAL_DB_TYPE: mysql + LOCAL_PHP_MEMCACHED: 'false' + COMPOSE_IGNORE_ORPHANS: 'true' + run: npm --prefix wordpress run env:logs || true + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: wp-e2e-${{ matrix.backend }}-artifacts + path: | + wordpress/artifacts + wordpress/src/wp-content/debug.log + if-no-files-found: ignore + - name: Stop Docker containers if: always() + env: + WP_TEST_DB_BACKEND: ${{ matrix.backend }} + LOCAL_DB_TYPE: mysql + LOCAL_PHP_MEMCACHED: 'false' run: composer run wp-test-clean diff --git a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh index 943b06c59..dbc8b2bf3 100644 --- a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh +++ b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh @@ -14,6 +14,11 @@ if [ ! -f "$COMPOSE_OVERRIDE" ]; then exit 1 fi +if ! grep -Fq 'DB_ENGINE: sqlite' "$COMPOSE_OVERRIDE" || ! grep -Fq 'DATABASE_ENGINE: sqlite' "$COMPOSE_OVERRIDE"; then + echo "Stale $COMPOSE_OVERRIDE. Run WP_TEST_DB_BACKEND=sqlite composer run wp-setup before this helper." >&2 + exit 1 +fi + add_volume_to_service() { local service="$1" local volume="$2" diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ae4f5f6a3..b8744104f 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -5,18 +5,22 @@ * 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 phpunitFilter = getPhpUnitFilter( phpunitArgs ); -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 +70,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 +94,895 @@ const expectedFailures = [ 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', ]; -console.log( 'Running WordPress PHPUnit tests with expected failures tracking...' ); +const postgresqlExpectedFailures = [ + 'Tests_DB::test_mysqli_flush_sync', +]; + +const expectedByBackend = { + mysql: { + errors: [], + failures: [], + }, + sqlite: { + errors: sqliteExpectedErrors, + failures: sqliteExpectedFailures, + }, + postgresql: { + errors: [], + failures: postgresqlExpectedFailures, + }, +}; + +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 cliArgs = process.argv.slice( 2 ); + + if ( hasPhpUnitFilter( cliArgs ) || ! process.env.WP_TEST_PHPUNIT_FILTER ) { + return cliArgs; + } + + return [ '--filter', process.env.WP_TEST_PHPUNIT_FILTER, ...cliArgs ]; +} + +function getPhpUnitFilter( args ) { + for ( let i = 0; i < args.length; i++ ) { + if ( '--filter' === args[ i ] ) { + return args[ i + 1 ] || ''; + } + if ( args[ i ].startsWith( '--filter=' ) ) { + return args[ i ].slice( '--filter='.length ); + } + } + + return ''; +} + +function hasPhpUnitFilter( args ) { + return args.some( arg => '--filter' === arg || arg.startsWith( '--filter=' ) ); +} + +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: phpunitFilter, + total: 0, + passed: 0, + errors: 0, + failures: 0, + skipped: 0, + incomplete: 0, + risky: 0, + warnings: 0, + }; +} + +function writeResultSummary( summary ) { + const outputPath = getResultSummaryFile(); + fs.writeFileSync( outputPath, `${ JSON.stringify( summary, null, 2 ) }\n` ); + + if ( process.env.GITHUB_OUTPUT ) { + const output = [ + `backend=${ summary.backend }`, + `total=${ summary.total }`, + `passed=${ summary.passed }`, + `errors=${ summary.errors }`, + `failures=${ summary.failures }`, + ].join( '\n' ); + fs.appendFileSync( process.env.GITHUB_OUTPUT, `${ output }\n` ); + } +} + +function getResultSummaryFile() { + return path.join( repositoryRoot, `wp-phpunit-results-${ backend }.json` ); +} diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 810b77b8a..4cb359d4c 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -4,15 +4,20 @@ on: push: branches: - main + - trunk pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. permissions: {} jobs: - test: - name: WordPress PHPUnit Tests + sqlite-test: + name: WordPress PHPUnit Tests / SQLite runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -31,12 +36,205 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - name: Run WordPress PHPUnit tests + env: + WP_TEST_DB_BACKEND: sqlite run: node .github/workflows/wp-tests-phpunit-run.js + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-sqlite + path: wp-phpunit-results-sqlite.json + if-no-files-found: warn + - name: Stop Docker containers if: always() run: composer run wp-test-clean + postgresql-test: + name: WordPress PHPUnit Tests / PostgreSQL + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Run WordPress PHPUnit tests + env: + WP_TEST_DB_BACKEND: postgresql + run: node .github/workflows/wp-tests-phpunit-run.js + + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-postgresql + path: wp-phpunit-results-postgresql.json + if-no-files-found: warn + + - name: Stop Docker containers + if: always() + env: + LOCAL_DB_TYPE: mysql + LOCAL_PHP_MEMCACHED: 'false' + run: | + if [ -f wordpress/docker-compose.yml ]; then + cd wordpress + if [ -f docker-compose.override.yml ]; then + docker compose -f docker-compose.yml -f docker-compose.override.yml down -v --remove-orphans + else + docker compose -f docker-compose.yml down -v --remove-orphans + fi + rm -rf src/wp-content/database/.ht.sqlite + fi + + update-pr-description: + name: Update PR PHPUnit Progress + needs: + - sqlite-test + - postgresql-test + if: github.event_name == 'pull_request' && always() + runs-on: ubuntu-latest + permissions: + actions: read # Required to download artifacts. + contents: read + pull-requests: write + + steps: + - name: Download PHPUnit count artifacts + uses: actions/download-artifact@v4 + with: + path: wp-phpunit-artifacts + pattern: wp-phpunit-sqlite + + - name: Download PostgreSQL PHPUnit count artifact + uses: actions/download-artifact@v4 + with: + path: wp-phpunit-artifacts + pattern: wp-phpunit-postgresql + + - name: Update PR description + uses: actions/github-script@v7 + with: + script: | + const fs = require( 'fs' ); + const path = require( 'path' ); + + const artifactRoot = path.join( process.cwd(), 'wp-phpunit-artifacts' ); + const startMarker = ''; + const endMarker = ''; + + function findResultFile( backend ) { + const expected = `wp-phpunit-results-${ backend }.json`; + const stack = [ artifactRoot ]; + + while ( stack.length > 0 ) { + const current = stack.pop(); + if ( ! fs.existsSync( current ) ) { + continue; + } + + const stat = fs.statSync( current ); + if ( stat.isDirectory() ) { + for ( const child of fs.readdirSync( current ) ) { + stack.push( path.join( current, child ) ); + } + continue; + } + + if ( path.basename( current ) === expected ) { + return current; + } + } + + return null; + } + + function readResult( backend ) { + const file = findResultFile( backend ); + if ( ! file ) { + return { backend, total: 0, passed: 0 }; + } + + return JSON.parse( fs.readFileSync( file, 'utf8' ) ); + } + + function formatNumber( value ) { + return Number( value || 0 ).toLocaleString( 'en-US' ); + } + + function renderProgressBar( current, target ) { + const width = 20; + const ratio = target > 0 ? Math.min( current / target, 1 ) : 0; + const filled = Math.round( ratio * width ); + const percent = target > 0 ? Math.round( ratio * 100 ) : 0; + return `[${ '#'.repeat( filled ) }${ '-'.repeat( width - filled ) }] ${ percent }%`; + } + + const sqlite = readResult( 'sqlite' ); + const postgresql = readResult( 'postgresql' ); + const postgresqlLabel = postgresql.filter + ? `PostgreSQL ${ formatNumber( postgresql.passed ) }/${ formatNumber( postgresql.total ) } passed with filter \`${ postgresql.filter }\`` + : `PostgreSQL ${ formatNumber( postgresql.passed ) } passed`; + const generated = [ + startMarker, + `WordPress PHPUnit: SQLite ${ formatNumber( sqlite.passed ) } passed; ${ postgresqlLabel }`, + postgresql.filter ? 'PostgreSQL is running a bounded PR validation subset.' : `\`${ renderProgressBar( postgresql.passed, sqlite.passed ) }\``, + endMarker, + ].join( '\n' ); + + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const headRepo = context.payload.pull_request.head.repo.full_name; + const baseRepo = `${ owner }/${ repo }`; + + await core.summary + .addRaw( generated ) + .addRaw( '\n' ) + .write(); + + if ( headRepo !== baseRepo ) { + core.warning( 'Skipping PR body update for forked PR because pull_request GITHUB_TOKEN is read-only.' ); + return; + } + + const pull = await github.rest.pulls.get( { owner, repo, pull_number } ); + if ( pull.data.head.sha !== context.payload.pull_request.head.sha ) { + core.warning( 'Skipping PR body update because a newer PR head is available.' ); + return; + } + + const body = pull.data.body || ''; + const startIndex = body.indexOf( startMarker ); + const endIndex = body.indexOf( endMarker ); + + let nextBody; + if ( startIndex !== -1 && endIndex !== -1 && endIndex > startIndex ) { + nextBody = [ + body.slice( 0, startIndex ), + generated, + body.slice( endIndex + endMarker.length ), + ].join( '' ); + } else if ( body.trim().length > 0 ) { + nextBody = `${ body }\n\n${ generated }`; + } else { + nextBody = generated; + } + + await github.rest.pulls.update( { owner, repo, pull_number, body: nextBody } ); + native-parser-test: name: WordPress PHPUnit Tests / Rust extension runs-on: ubuntu-latest @@ -57,6 +255,8 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - name: Set up WordPress test environment + env: + WP_TEST_DB_BACKEND: sqlite run: composer run wp-setup - name: Build and load parser extension in WordPress PHP containers @@ -65,8 +265,17 @@ jobs: - name: Run WordPress PHPUnit tests with parser extension env: WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '1' + WP_TEST_DB_BACKEND: sqlite run: node .github/workflows/wp-tests-phpunit-run.js + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-sqlite-native + path: wp-phpunit-results-sqlite.json + if-no-files-found: warn + - name: Stop Docker containers if: always() run: composer run wp-test-clean diff --git a/.gitignore b/.gitignore index b75ec524b..f5649a0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ vendor/ composer.lock .idea/ .phpunit.result.cache +/wp-phpunit-results-*.json ._.DS_Store .DS_Store ._* diff --git a/bin/build-sqlite-plugin-zip.sh b/bin/build-sqlite-plugin-zip.sh index fcb80e7ef..d6d4faf8a 100755 --- a/bin/build-sqlite-plugin-zip.sh +++ b/bin/build-sqlite-plugin-zip.sh @@ -26,6 +26,10 @@ cp -R "$DIR/packages/plugin-sqlite-database-integration" "$PLUGIN_DIR" rm "$PLUGIN_DIR/wp-includes/database" cp -R "$DIR/packages/mysql-on-sqlite/src" "$PLUGIN_DIR/wp-includes/database" +# Verify the copied driver payload can load before packaging. +PLUGIN_DRIVER_LOAD="$PLUGIN_DIR/wp-includes/database/load.php" +php -r 'require $argv[1]; if ( ! class_exists( "WP_PostgreSQL_Driver", false ) ) { fwrite( STDERR, "PostgreSQL driver failed to load.\n" ); exit( 1 ); }' "$PLUGIN_DRIVER_LOAD" + # Remove dev-only files. rm -rf "$PLUGIN_DIR/composer.json" rm -rf "$PLUGIN_DIR/vendor" diff --git a/bin/verify-workflow-ci-gates.js b/bin/verify-workflow-ci-gates.js new file mode 100644 index 000000000..298dfd70e --- /dev/null +++ b/bin/verify-workflow-ci-gates.js @@ -0,0 +1,322 @@ +#!/usr/bin/env node + +const { execSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const workflowDir = path.join( root, '.github', 'workflows' ); +const failures = []; +const missingJobs = new Set(); + +function readWorkflow( filename ) { + return fs.readFileSync( path.join( workflowDir, filename ), 'utf8' ); +} + +function fail( filename, message ) { + failures.push( `${ filename }: ${ message }` ); +} + +function getRemoteDefaultBranch() { + for ( const ref of [ 'refs/remotes/upstream/HEAD', 'refs/remotes/origin/HEAD' ] ) { + try { + const output = execSync( `git symbolic-ref --quiet --short ${ ref }`, { + cwd: root, + encoding: 'utf8', + stdio: [ 'ignore', 'pipe', 'ignore' ], + } ).trim(); + + if ( output.includes( '/' ) ) { + return output.split( '/' ).pop(); + } + } catch ( error ) { + // Fall through to the next ref or static default below. + } + } + + return 'trunk'; +} + +const defaultBranch = process.env.DEFAULT_BRANCH || getRemoteDefaultBranch(); + +function getPushBranches( contents ) { + const lines = contents.split( /\r?\n/ ); + const branches = []; + + for ( let i = 0; i < lines.length; i++ ) { + if ( lines[ i ] !== ' push:' ) { + continue; + } + + for ( i++; i < lines.length; i++ ) { + const line = lines[ i ]; + if ( line.startsWith( ' ' ) && ! line.startsWith( ' ' ) ) { + break; + } + + if ( line !== ' branches:' ) { + continue; + } + + for ( i++; i < lines.length; i++ ) { + const branchLine = lines[ i ]; + if ( ! branchLine.startsWith( ' - ' ) ) { + break; + } + branches.push( branchLine.slice( 8 ).trim().replace( /^['"]|['"]$/g, '' ) ); + } + + return branches; + } + } + + return branches; +} + +function getJobBlock( contents, jobName ) { + const lines = contents.split( /\r?\n/ ); + const start = lines.findIndex( ( line ) => line === ` ${ jobName }:` ); + if ( -1 === start ) { + return ''; + } + + let end = lines.length; + for ( let i = start + 1; i < lines.length; i++ ) { + if ( /^ [A-Za-z0-9_-]+:$/.test( lines[ i ] ) ) { + end = i; + break; + } + } + + return lines.slice( start, end ).join( '\n' ); +} + +function getRequiredJobBlock( filename, jobName ) { + const block = getJobBlock( readWorkflow( filename ), jobName ); + if ( block ) { + return block; + } + + const key = `${ filename }:${ jobName }`; + if ( ! missingJobs.has( key ) ) { + fail( filename, `missing ${ jobName } job.` ); + missingJobs.add( key ); + } + + return ''; +} + +function getStepBlockContaining( jobBlock, needle ) { + const lines = jobBlock.split( /\r?\n/ ); + const needleIndex = lines.findIndex( ( line ) => line.includes( needle ) ); + if ( -1 === needleIndex ) { + return ''; + } + + let start = needleIndex; + for ( ; start >= 0; start-- ) { + if ( /^ - /.test( lines[ start ] ) ) { + break; + } + } + + if ( start < 0 ) { + return lines[ needleIndex ]; + } + + let end = lines.length; + for ( let i = start + 1; i < lines.length; i++ ) { + if ( /^ - /.test( lines[ i ] ) ) { + end = i; + break; + } + } + + return lines.slice( start, end ).join( '\n' ); +} + +function assertNoContinueOnError( filename ) { + const contents = readWorkflow( filename ); + if ( contents.includes( 'continue-on-error' ) ) { + fail( filename, 'must not use continue-on-error; failing jobs should fail CI.' ); + } +} + +function assertDefaultBranchPush( filename ) { + const contents = readWorkflow( filename ); + const branches = getPushBranches( contents ); + if ( ! branches.includes( defaultBranch ) ) { + fail( filename, `push.branches must include repository default branch "${ defaultBranch }".` ); + } + + for ( const branch of [ 'main', 'trunk' ] ) { + if ( ! branches.includes( branch ) ) { + fail( filename, `push.branches must include "${ branch }" to avoid fork/upstream default-branch skips.` ); + } + } +} + +function assertJobHasNoTopLevelIf( filename, jobName ) { + const block = getRequiredJobBlock( filename, jobName ); + if ( ! block ) { + return; + } + + if ( /^ if:/m.test( block ) ) { + fail( filename, `${ jobName } job must not have a job-level if gate.` ); + } +} + +function assertJobIncludes( filename, jobName, needle, message ) { + const block = getRequiredJobBlock( filename, jobName ); + if ( ! block ) { + return; + } + + if ( ! block.includes( needle ) ) { + fail( filename, message ); + } +} + +function assertJobRunStepHasNoIf( filename, jobName, command, message ) { + const block = getRequiredJobBlock( filename, jobName ); + if ( ! block ) { + return; + } + + const stepBlock = getStepBlockContaining( block, command ); + if ( ! stepBlock || ! /^( - run:| run:)/m.test( stepBlock ) ) { + fail( filename, message ); + return; + } + + if ( /^( - if:| if:)/m.test( stepBlock ) ) { + fail( filename, `${ jobName } run step for "${ command }" must not have a step-level if gate.` ); + } +} + +function assertIncludes( filename, needle, message ) { + if ( ! readWorkflow( filename ).includes( needle ) ) { + fail( filename, message ); + } +} + +for ( const filename of fs.readdirSync( workflowDir ).filter( ( file ) => file.endsWith( '.yml' ) ) ) { + assertNoContinueOnError( filename ); +} + +for ( const filename of [ + 'phpunit-tests.yml', + 'wp-tests-phpunit.yml', + 'end-to-end-tests.yml', + 'wp-tests-end-to-end.yml', +] ) { + assertDefaultBranchPush( filename ); +} + +assertJobHasNoTopLevelIf( 'phpunit-tests.yml', 'postgresql-test' ); +assertJobIncludes( + 'phpunit-tests.yml', + 'postgresql-test', + 'image: postgres:16', + 'package PostgreSQL PHPUnit job must define a PostgreSQL service.' +); +assertJobIncludes( + 'phpunit-tests.yml', + 'postgresql-test', + 'extensions: pdo_pgsql', + 'package PostgreSQL PHPUnit job must install the pdo_pgsql extension.' +); +assertJobIncludes( + 'phpunit-tests.yml', + 'postgresql-test', + 'PGSQL_TEST_DSN: pgsql:host=127.0.0.1;port=5432;dbname=wordpress_develop', + 'package PostgreSQL PHPUnit job must set PGSQL_TEST_DSN.' +); +assertJobRunStepHasNoIf( + 'phpunit-tests.yml', + 'postgresql-test', + 'composer run test-postgresql', + 'package PostgreSQL PHPUnit job must run composer run test-postgresql.' +); + +assertJobHasNoTopLevelIf( 'wp-tests-phpunit.yml', 'postgresql-test' ); +assertJobIncludes( + 'wp-tests-phpunit.yml', + 'postgresql-test', + 'WP_TEST_DB_BACKEND: postgresql', + 'WordPress PostgreSQL PHPUnit job must set WP_TEST_DB_BACKEND=postgresql.' +); +assertJobRunStepHasNoIf( + 'wp-tests-phpunit.yml', + 'postgresql-test', + 'node .github/workflows/wp-tests-phpunit-run.js', + 'WordPress PostgreSQL PHPUnit job must run the PHPUnit helper.' +); + +for ( const [ filename, composerCommand ] of [ + [ 'end-to-end-tests.yml', 'composer run test-e2e' ], + [ 'wp-tests-end-to-end.yml', 'composer run wp-test-e2e' ], +] ) { + assertJobHasNoTopLevelIf( filename, 'test' ); + assertIncludes( filename, ' pull_request:', 'e2e workflow must run for pull requests.' ); + assertIncludes( filename, `run: ${ composerCommand }`, `e2e workflow must run ${ composerCommand }.` ); +} + +assertIncludes( + 'end-to-end-tests.yml', + 'WP_TEST_DB_BACKEND: sqlite', + 'plugin Query Monitor e2e workflow must explicitly run the SQLite backend.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + 'backend:', + 'WordPress e2e workflow must define a database backend matrix.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + '- postgresql', + 'WordPress e2e workflow matrix must include PostgreSQL.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + 'WP_TEST_DB_BACKEND: ${{ matrix.backend }}', + 'WordPress e2e workflow must pass the selected database backend to composer.' +); + +const progressBlock = getJobBlock( readWorkflow( 'wp-tests-phpunit.yml' ), 'update-pr-description' ); +if ( ! progressBlock ) { + fail( 'wp-tests-phpunit.yml', 'missing update-pr-description job.' ); +} else { + for ( const needed of [ 'sqlite-test', 'postgresql-test' ] ) { + if ( ! progressBlock.includes( ` - ${ needed }` ) ) { + fail( 'wp-tests-phpunit.yml', `update-pr-description must need ${ needed }.` ); + } + } + + if ( ! progressBlock.includes( "if: github.event_name == 'pull_request' && always()" ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must be PR-only and informational.' ); + } + + if ( /\n (checks|statuses):\s*write/m.test( progressBlock ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must not write checks or commit statuses.' ); + } + + if ( ! progressBlock.includes( 'core.summary' ) || ! progressBlock.includes( 'github.rest.pulls.update' ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must only publish summary/PR body progress.' ); + } + + if ( /github\.rest\.(checks|repos\.createCommitStatus)/.test( progressBlock ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must not create checks or commit statuses.' ); + } +} + +if ( failures.length > 0 ) { + console.error( 'Workflow CI gate verification failed:' ); + for ( const failure of failures ) { + console.error( `- ${ failure }` ); + } + process.exit( 1 ); +} + +console.log( `Workflow CI gate verification passed for default branch "${ defaultBranch }".` ); diff --git a/bin/wp-test-install-postgresql-site.js b/bin/wp-test-install-postgresql-site.js new file mode 100644 index 000000000..313d3ccf5 --- /dev/null +++ b/bin/wp-test-install-postgresql-site.js @@ -0,0 +1,440 @@ +#!/usr/bin/env node + +const { execFileSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const backend = normalizeBackend( process.env.WP_TEST_DB_BACKEND || 'sqlite' ); + +if ( 'postgresql' !== backend ) { + process.exit( 0 ); +} + +const wordpressDir = path.join( root, 'wordpress' ); +if ( ! fs.existsSync( path.join( wordpressDir, 'package.json' ) ) ) { + throw new Error( 'Generated WordPress checkout is missing. Run composer run wp-setup first.' ); +} + +if ( shouldInstallRequestDiagnostics() ) { + installRequestDiagnostics(); +} else { + removeRequestDiagnostics(); +} + +if ( isWordPressInstalled() ) { + console.log( 'PostgreSQL WordPress site is already installed.' ); + process.exit( 0 ); +} + +console.log( 'Installing PostgreSQL WordPress site for e2e tests...' ); +runEnvCli( [ + 'core', + 'install', + '--path=/var/www/src', + `--url=${ getBaseUrl() }`, + '--title=WordPress', + '--admin_user=admin', + '--admin_password=password', + '--admin_email=test@test.com', + '--skip-email', +] ); + +function isWordPressInstalled() { + try { + runEnvCli( [ 'core', 'is-installed', '--path=/var/www/src' ], 'ignore' ); + return true; + } catch ( error ) { + return false; + } +} + +function runEnvCli( args, stdio = 'inherit' ) { + execFileSync( + 'npm', + [ + '--prefix', + 'wordpress', + 'run', + 'env:cli', + '--', + ...args, + ], + { + cwd: root, + env: getDockerEnv(), + stdio, + } + ); +} + +function getDockerEnv() { + return { + ...process.env, + LOCAL_DB_TYPE: process.env.LOCAL_DB_TYPE || 'mysql', + LOCAL_PHP_MEMCACHED: process.env.LOCAL_PHP_MEMCACHED || 'false', + COMPOSE_IGNORE_ORPHANS: 'true', + }; +} + +function getBaseUrl() { + const env = readWordPressDotenv(); + const port = process.env.LOCAL_PORT || env.LOCAL_PORT || '8889'; + return process.env.WP_BASE_URL || expandEnvValue( env.WP_BASE_URL || 'http://localhost:${LOCAL_PORT}', { ...env, LOCAL_PORT: port } ); +} + +function readWordPressDotenv() { + const file = path.join( wordpressDir, '.env' ); + if ( ! fs.existsSync( file ) ) { + return {}; + } + + const values = {}; + for ( const line of fs.readFileSync( file, 'utf8' ).split( /\r?\n/ ) ) { + const match = line.match( /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/ ); + if ( match ) { + values[ match[1] ] = match[2]; + } + } + + return values; +} + +function expandEnvValue( value, values ) { + return String( value ).replace( /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ( match, name ) => values[ name ] || process.env[ name ] || '' ); +} + +function normalizeBackend( value ) { + const normalized = String( value ).toLowerCase(); + if ( [ 'postgres', 'pgsql', 'postgresql' ].includes( normalized ) ) { + return 'postgresql'; + } + if ( [ 'mysql', 'sqlite' ].includes( normalized ) ) { + return normalized; + } + + throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ value }` ); +} + +function shouldInstallRequestDiagnostics() { + return /^(?:1|true|yes|on)$/i.test( process.env.WP_POSTGRESQL_E2E_REQUEST_DIAGNOSTICS || '' ); +} + +function getRequestDiagnosticsPath() { + return path.join( wordpressDir, 'src', 'wp-content', 'mu-plugins', 'postgresql-e2e-request-diagnostics.php' ); +} + +function installRequestDiagnostics() { + const diagnosticsPath = getRequestDiagnosticsPath(); + + fs.mkdirSync( path.dirname( diagnosticsPath ), { recursive: true } ); + fs.writeFileSync( diagnosticsPath, getRequestDiagnosticsPlugin() ); + console.log( `Installed PostgreSQL E2E request diagnostics at ${ diagnosticsPath }.` ); +} + +function removeRequestDiagnostics() { + const diagnosticsPath = getRequestDiagnosticsPath(); + + fs.rmSync( diagnosticsPath, { force: true } ); +} + +function getRequestDiagnosticsPlugin() { + return String.raw` microtime( true ), + 'query_count' => 0, + 'last_query' => '', + 'last_query_time' => 0.0, + 'request' => $wp_postgresql_e2e_diag_request, +); + +wp_postgresql_e2e_diag_send_request_id_header(); +wp_postgresql_e2e_diag_log( 'request-start' ); + +add_filter( 'query', 'wp_postgresql_e2e_diag_record_query_start', PHP_INT_MAX ); +add_filter( 'rest_pre_dispatch', 'wp_postgresql_e2e_diag_rest_pre_dispatch', 10, 3 ); +add_filter( 'rest_post_dispatch', 'wp_postgresql_e2e_diag_rest_post_dispatch', 10, 3 ); +add_action( 'shutdown', 'wp_postgresql_e2e_diag_shutdown', PHP_INT_MAX ); + +function wp_postgresql_e2e_diag_get_target_request() { + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : ''; + $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : ''; + $path = (string) parse_url( $uri, PHP_URL_PATH ); + $query = (string) parse_url( $uri, PHP_URL_QUERY ); + $query_args = array(); + + if ( '' !== $query ) { + parse_str( $query, $query_args ); + } + + $rest_route = ''; + if ( isset( $query_args['rest_route'] ) && is_scalar( $query_args['rest_route'] ) ) { + $rest_route = rawurldecode( (string) $query_args['rest_route'] ); + } + + $pretty_rest_path = preg_replace( '#^.*?/wp-json/#', '/wp-json/', $path ); + $is_posts_rest = (bool) preg_match( '#^/wp-json/wp/v2/posts/[0-9]+/?$#', $pretty_rest_path ) + || (bool) preg_match( '#^/wp/v2/posts/[0-9]+/?$#', $rest_route ); + $is_user_rest = '/wp-json/wp/v2/users/me' === $pretty_rest_path + || '/wp/v2/users/me' === $rest_route; + $is_admin_ajax = false !== strpos( $path, '/wp-admin/admin-ajax.php' ); + + if ( 'POST' === $method && $is_posts_rest ) { + $kind = 'rest-post-save'; + } elseif ( 'POST' === $method && $is_user_rest ) { + $kind = 'rest-users-me'; + } elseif ( 'POST' === $method && $is_admin_ajax ) { + $kind = 'admin-ajax'; + } else { + return false; + } + + return array( + 'kind' => $kind, + 'method' => $method, + 'uri' => wp_postgresql_e2e_diag_uri_shape( $uri ), + 'path' => $path, + 'rest_route' => $rest_route, + 'action' => isset( $_REQUEST['action'] ) && is_scalar( $_REQUEST['action'] ) + ? wp_postgresql_e2e_diag_truncate( (string) $_REQUEST['action'], 120 ) + : '', + 'request_id' => wp_postgresql_e2e_diag_request_id(), + ); +} + +function wp_postgresql_e2e_diag_send_request_id_header() { + $request_id = wp_postgresql_e2e_diag_request_id(); + if ( '' !== $request_id && ! headers_sent() ) { + header( 'X-WP-PostgreSQL-E2E-Request-ID: ' . $request_id ); + } +} + +function wp_postgresql_e2e_diag_record_query_start( $query ) { + $GLOBALS['wp_postgresql_e2e_diag_state']['query_count']++; + $GLOBALS['wp_postgresql_e2e_diag_state']['last_query'] = wp_postgresql_e2e_diag_sql_shape( $query ); + $GLOBALS['wp_postgresql_e2e_diag_state']['last_query_time'] = microtime( true ); + + wp_postgresql_e2e_diag_log( + 'query-start', + array( + 'query_number' => $GLOBALS['wp_postgresql_e2e_diag_state']['query_count'], + 'elapsed_ms' => wp_postgresql_e2e_diag_elapsed_ms(), + 'sql' => $GLOBALS['wp_postgresql_e2e_diag_state']['last_query'], + ) + ); + + return $query; +} + +function wp_postgresql_e2e_diag_rest_pre_dispatch( $result, $server, $request ) { + wp_postgresql_e2e_diag_log( + 'rest-pre-dispatch', + array( + 'route' => is_object( $request ) && method_exists( $request, 'get_route' ) ? $request->get_route() : '', + 'method' => is_object( $request ) && method_exists( $request, 'get_method' ) ? $request->get_method() : '', + ) + ); + + return $result; +} + +function wp_postgresql_e2e_diag_rest_post_dispatch( $result, $server, $request ) { + $status = null; + if ( is_object( $result ) && method_exists( $result, 'get_status' ) ) { + $status = $result->get_status(); + } + + wp_postgresql_e2e_diag_log( + 'rest-post-dispatch', + array( + 'route' => is_object( $request ) && method_exists( $request, 'get_route' ) ? $request->get_route() : '', + 'method' => is_object( $request ) && method_exists( $request, 'get_method' ) ? $request->get_method() : '', + 'status' => $status, + ) + ); + + return $result; +} + +function wp_postgresql_e2e_diag_shutdown() { + global $wpdb; + + $last_error = ''; + if ( isset( $wpdb ) && is_object( $wpdb ) && ! empty( $wpdb->last_error ) ) { + $last_error = wp_postgresql_e2e_diag_truncate( (string) $wpdb->last_error, 600 ); + } + + wp_postgresql_e2e_diag_log( + 'request-end', + array( + 'elapsed_ms' => wp_postgresql_e2e_diag_elapsed_ms(), + 'response_code' => function_exists( 'http_response_code' ) ? http_response_code() : null, + 'connection_status' => connection_status(), + 'connection_aborted' => connection_aborted(), + 'started_queries' => $GLOBALS['wp_postgresql_e2e_diag_state']['query_count'], + 'completed_queries' => wp_postgresql_e2e_diag_completed_query_count(), + 'last_started_query' => $GLOBALS['wp_postgresql_e2e_diag_state']['last_query'], + 'last_completed_query' => wp_postgresql_e2e_diag_last_completed_query(), + 'slowest_queries' => wp_postgresql_e2e_diag_slowest_queries(), + 'db_last_error' => $last_error, + ) + ); +} + +function wp_postgresql_e2e_diag_completed_query_count() { + global $wpdb; + + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || empty( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + return 0; + } + + return count( $wpdb->queries ); +} + +function wp_postgresql_e2e_diag_last_completed_query() { + global $wpdb; + + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || empty( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + return ''; + } + + $last = end( $wpdb->queries ); + reset( $wpdb->queries ); + + return isset( $last[0] ) ? wp_postgresql_e2e_diag_sql_shape( $last[0] ) : ''; +} + +function wp_postgresql_e2e_diag_slowest_queries() { + global $wpdb; + + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || empty( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + return array(); + } + + $queries = array(); + foreach ( $wpdb->queries as $query ) { + if ( ! isset( $query[0], $query[1] ) ) { + continue; + } + + $queries[] = array( + 'elapsed_ms' => round( (float) $query[1] * 1000, 3 ), + 'sql' => wp_postgresql_e2e_diag_sql_shape( $query[0] ), + ); + } + + usort( $queries, 'wp_postgresql_e2e_diag_compare_query_elapsed' ); + + return array_slice( $queries, 0, 8 ); +} + +function wp_postgresql_e2e_diag_compare_query_elapsed( $a, $b ) { + if ( $a['elapsed_ms'] === $b['elapsed_ms'] ) { + return 0; + } + + return ( $a['elapsed_ms'] < $b['elapsed_ms'] ) ? 1 : -1; +} + +function wp_postgresql_e2e_diag_elapsed_ms() { + return round( ( microtime( true ) - $GLOBALS['wp_postgresql_e2e_diag_state']['started_at'] ) * 1000, 3 ); +} + +function wp_postgresql_e2e_diag_log( $event, $context = array() ) { + $request = isset( $GLOBALS['wp_postgresql_e2e_diag_state']['request'] ) + ? $GLOBALS['wp_postgresql_e2e_diag_state']['request'] + : array(); + + $payload = array_merge( + array( + 'event' => $event, + 'request_id' => isset( $request['request_id'] ) ? $request['request_id'] : '', + 'kind' => isset( $request['kind'] ) ? $request['kind'] : '', + 'method' => isset( $request['method'] ) ? $request['method'] : '', + 'uri' => isset( $request['uri'] ) ? $request['uri'] : '', + 'rest_route' => isset( $request['rest_route'] ) ? $request['rest_route'] : '', + 'action' => isset( $request['action'] ) ? $request['action'] : '', + ), + $context + ); + + error_log( '[postgresql-e2e-request-diagnostics] ' . wp_postgresql_e2e_diag_json_encode( $payload ) ); +} + +function wp_postgresql_e2e_diag_json_encode( $payload ) { + if ( function_exists( 'wp_json_encode' ) ) { + return wp_json_encode( $payload, JSON_UNESCAPED_SLASHES ); + } + + return json_encode( $payload, JSON_UNESCAPED_SLASHES ); +} + +function wp_postgresql_e2e_diag_request_id() { + $request_id = isset( $_SERVER['HTTP_X_REQUEST_ID'] ) ? (string) $_SERVER['HTTP_X_REQUEST_ID'] : ''; + + return preg_replace( '/[^A-Za-z0-9_.:-]/', '', $request_id ); +} + +function wp_postgresql_e2e_diag_uri_shape( $uri ) { + $parts = parse_url( $uri ); + if ( ! is_array( $parts ) ) { + return wp_postgresql_e2e_diag_truncate( $uri, 300 ); + } + + $path = isset( $parts['path'] ) ? $parts['path'] : ''; + if ( empty( $parts['query'] ) ) { + return $path; + } + + $query_args = array(); + parse_str( $parts['query'], $query_args ); + + $shaped_args = array(); + foreach ( $query_args as $key => $value ) { + $key = wp_postgresql_e2e_diag_truncate( (string) $key, 80 ); + if ( 'rest_route' === $key && is_scalar( $value ) ) { + $shaped_args[] = rawurlencode( $key ) . '=' . rawurlencode( rawurldecode( (string) $value ) ); + } else { + $shaped_args[] = rawurlencode( $key ) . '=?'; + } + } + + return wp_postgresql_e2e_diag_truncate( $path . '?' . implode( '&', $shaped_args ), 500 ); +} + +function wp_postgresql_e2e_diag_sql_shape( $sql ) { + $sql = preg_replace( "/'(?:\\\\.|''|[^'\\\\])*'/s", "'?'", (string) $sql ); + $sql = preg_replace( '/\s+/', ' ', $sql ); + $sql = trim( $sql ); + + return wp_postgresql_e2e_diag_truncate( $sql, 1200 ); +} + +function wp_postgresql_e2e_diag_truncate( $value, $length ) { + $value = (string) $value; + if ( strlen( $value ) <= $length ) { + return $value; + } + + return substr( $value, 0, $length - 3 ) . '...'; +} +`; +} diff --git a/composer.json b/composer.json index 689b44ed7..b00cdeaa2 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "check-cs": [ "@php ./vendor/bin/phpcs" ], + "verify-workflow-ci-gates": [ + "node ./bin/verify-workflow-ci-gates.js" + ], "fix-cs": [ "@php ./vendor/bin/phpcbf" ], @@ -42,6 +45,7 @@ ], "test-e2e": [ "@wp-test-ensure-env @no_additional_args", + "npm --prefix wordpress run env:cli -- plugin install query-monitor --force @no_additional_args", "rm -f tests/e2e/package.json tests/e2e/node_modules @no_additional_args", "ln -s ../../wordpress/package.json tests/e2e/package.json @no_additional_args", "ln -s ../../wordpress/node_modules tests/e2e/node_modules @no_additional_args", @@ -54,15 +58,22 @@ "npm --prefix wordpress run" ], "wp-test-start": [ + "@wp-test-ensure-backend @no_additional_args", + "@wp-test-ensure-wordpress-node-deps @no_additional_args", + "@putenv COMPOSE_IGNORE_ORPHANS=true", "npm --prefix wordpress run env:start", "npm --prefix wordpress run env:install", "npm --prefix wordpress run env:cli -- plugin install gutenberg --version=22.3.0", "npm --prefix wordpress run env:cli -- plugin install query-monitor" ], + "wp-test-ensure-backend": "node -e 'const { execSync } = require( \"child_process\" ); const fs = require( \"fs\" ); const quote = String.fromCharCode( 39 ); const aliases = new Map( [ [ \"postgres\", \"postgresql\" ], [ \"pgsql\", \"postgresql\" ] ] ); let backend = String( process.env.WP_TEST_DB_BACKEND || \"sqlite\" ).toLowerCase(); backend = aliases.get( backend ) || backend; if ( ! [ \"mysql\", \"sqlite\", \"postgresql\" ].includes( backend ) ) { throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ backend }` ); } const checks = [ [ \"wordpress/src/wp-load.php\" ] ]; if ( \"mysql\" !== backend ) { const helperNeedles = \"postgresql\" === backend ? [ `require_once ABSPATH . ${ quote }wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php${ quote };`, \"class WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {\" ] : [ \"class WpdbExposedMethodsForTesting extends WP_SQLite_DB {\" ]; checks.push( [ \"wordpress/src/wp-content/db.php\", [ `: ${ quote }${ backend }${ quote }`, `/wp-includes/db.php${ quote }` ] ], [ \"wordpress/docker-compose.override.yml\", [ `DB_ENGINE: ${ backend }`, `DATABASE_ENGINE: ${ backend }` ] ], [ \"wordpress/tests/phpunit/includes/utils.php\", helperNeedles ] ); } if ( \"postgresql\" === backend ) { checks.push( [ \"wordpress/tools/local-env/Dockerfile.postgresql-php\", [ \"docker-php-ext-install pdo_pgsql\", \"git config --system --add safe.directory /var/www\" ] ], [ \"wordpress/tools/local-env/Dockerfile.postgresql-cli\", [ \"docker-php-ext-install pdo_pgsql\", \"git config --system --add safe.directory /var/www\" ] ], [ \"wordpress/tools/local-env/scripts/install.js\", [ \"--dbhost=postgres\", \"--skip-check\", \"DB_ENGINE postgresql\", \"DATABASE_ENGINE postgresql\", `const fs = require( ${ quote }fs${ quote } );`, `const { existsSync, renameSync, readFileSync, writeFileSync } = fs;`, \"install_postgresql_test_environment();\", \"write_postgresql_wp_config();\", \"write_postgresql_wp_tests_config();\", `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = [ \"wordpress/src/wp-content/db.php.bak\", \"wordpress/tests/phpunit/includes/utils.php.bak\", ...( \"mysql\" === backend ? [ \"wordpress/src/wp-content/db.php\", \"wordpress/docker-compose.override.yml\", \"wordpress/tools/local-env/Dockerfile.postgresql-php\", \"wordpress/tools/local-env/Dockerfile.postgresql-cli\" ] : [] ) ]; const validate = () => { const stale = []; for ( const [ file, needles = [] ] of checks ) { if ( ! fs.existsSync( file ) ) { stale.push( `${ file } is missing` ); continue; } const contents = needles.length ? fs.readFileSync( file, \"utf8\" ) : \"\"; for ( const needle of needles ) { if ( ! contents.includes( needle ) ) { stale.push( `${ file } lacks ${ needle }` ); } } } for ( const file of staleIfPresent ) { if ( fs.existsSync( file ) ) { stale.push( `${ file } should not exist for ${ backend }` ); } } return stale; }; const report = ( message, stale ) => { console.error( message ); stale.forEach( item => console.error( `- ${ item }` ) ); }; let stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is stale for ${ backend }; rerunning composer run wp-setup.`, stale ); execSync( \"composer run wp-setup\", { env: { ...process.env, WP_TEST_DB_BACKEND: backend, ...( \"postgresql\" === backend ? { WP_TEST_SKIP_WORDPRESS_NPM: \"1\" } : {} ) }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", + "wp-test-ensure-wordpress-node-deps": "node -e 'const { execSync } = require( \"child_process\" ); const fs = require( \"fs\" ); const aliases = new Map( [ [ \"postgres\", \"postgresql\" ], [ \"pgsql\", \"postgresql\" ] ] ); let backend = String( process.env.WP_TEST_DB_BACKEND || \"sqlite\" ).toLowerCase(); backend = aliases.get( backend ) || backend; if ( \"postgresql\" !== backend ) { process.exit( 0 ); } const required = [ \"wordpress/node_modules/dotenv/package.json\", \"wordpress/node_modules/dotenv-expand/package.json\", \"wordpress/node_modules/wait-on/package.json\" ]; const missing = required.filter( file => ! fs.existsSync( file ) ); if ( ! missing.length ) { process.exit( 0 ); } console.error( \"Generated WordPress checkout is missing or has broken npm dependencies for postgresql; rerunning composer run wp-setup.\" ); missing.forEach( file => console.error( `- ${ file } is missing` ) ); execSync( \"composer run wp-setup\", { env: { ...process.env, WP_TEST_DB_BACKEND: backend, WP_TEST_SKIP_WORDPRESS_NPM: \"1\" }, stdio: \"inherit\" } ); const stillMissing = required.filter( file => ! fs.existsSync( file ) ); if ( stillMissing.length ) { console.error( \"Generated WordPress checkout is still missing or has broken npm dependencies for postgresql.\" ); stillMissing.forEach( file => console.error( `- ${ file } is missing` ) ); process.exit( 1 ); }'", "wp-test-ensure-env": [ - "if [ ! -f wordpress/src/wp-load.php ]; then composer run wp-setup; fi", + "@wp-test-ensure-backend @no_additional_args", + "@wp-test-ensure-wordpress-node-deps @no_additional_args", "@putenv COMPOSE_IGNORE_ORPHANS=true", - "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" + "npm --prefix wordpress run env:start", + "npm --prefix wordpress run env:install" ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", @@ -71,8 +82,12 @@ ], "wp-test-e2e": [ "@wp-test-ensure-env @no_additional_args", + "@wp-test-install-postgresql-site @no_additional_args", + "npm --prefix wordpress run env:cli -- plugin install gutenberg --version=22.3.0", + "npm --prefix wordpress run env:cli -- plugin install query-monitor", "npm --prefix wordpress run test:e2e -- @additional_args" ], + "wp-test-install-postgresql-site": "node ./bin/wp-test-install-postgresql-site.js", "wp-test-clean": [ "npm --prefix wordpress run env:clean", "rm -rf wordpress/src/wp-content/database/.ht.sqlite" diff --git a/goal.md b/goal.md new file mode 100644 index 000000000..34f97dce3 --- /dev/null +++ b/goal.md @@ -0,0 +1,146 @@ +# PostgreSQL MySQL Compatibility Goals + +## Objective + +Keep MySQL queries working the same way across the PostgreSQL and SQLite backends. Prefer MySQL-compatible emulation in the query layer over relying on PostgreSQL-specific behavior. + +## Tasks + +- [x] Centralize SQL mode parsing and normalization so PostgreSQL does not store `sql_mode` as an opaque raw string. +- [x] Make PostgreSQL's default SQL modes match SQLite's defaults: + - `ERROR_FOR_DIVISION_BY_ZERO` + - `NO_ENGINE_SUBSTITUTION` + - `NO_ZERO_DATE` + - `NO_ZERO_IN_DATE` + - `ONLY_FULL_GROUP_BY` + - `STRICT_TRANS_TABLES` +- [x] Support the same session SQL mode syntaxes as SQLite: + - `SET sql_mode = ...` + - `SET @@sql_mode = ...` + - `SET SESSION sql_mode = ...` + - `SET @@SESSION.sql_mode = ...` + - user-variable save/restore flows such as `SET @old_sql_mode = @@SESSION.sql_mode` followed by `SET SESSION sql_mode = @old_sql_mode` +- [x] Ensure `SELECT @@sql_mode`, `SELECT @@SESSION.sql_mode`, `SELECT @@GLOBAL.sql_mode`, and `SHOW VARIABLES LIKE 'sql_mode'` report normalized emulated state. +- [x] Pass active SQL modes into every PostgreSQL MySQL lexer/parser construction, including direct lexer calls outside the main tokenization helper. +- [x] Add `ANSI_QUOTES` support to the PHP MySQL lexer. +- [x] Add `ANSI_QUOTES` support to the Rust native MySQL parser path. +- [x] When `ANSI_QUOTES` is active, tokenize double-quoted text as identifier-like, equivalent to backtick-quoted identifiers. +- [x] When `ANSI_QUOTES` is inactive, keep double-quoted text as string literals. +- [x] Preserve existing parser-mode behavior for: + - `NO_BACKSLASH_ESCAPES` + - `PIPES_AS_CONCAT` + - `IGNORE_SPACE` + - `HIGH_NOT_PRECEDENCE` +- [x] Update the PostgreSQL wpdb adapter so `set_sql_mode()` mirrors SQLite/core behavior when called without explicit modes. +- [x] Ensure PostgreSQL wpdb no-argument `set_sql_mode()` applies core incompatible-mode filtering to zero-date and strict modes instead of preserving backend defaults. +- [x] Stop treating `ANSI_QUOTES` as inherently incompatible for PostgreSQL once lexer/parser support exists. +- [x] Emulate `NO_AUTO_VALUE_ON_ZERO` for PostgreSQL INSERT translation against auto-increment columns. +- [x] Enforce `NO_ZERO_DATE` and `NO_ZERO_IN_DATE` before PostgreSQL receives invalid MySQL date values. +- [x] Enforce strict-mode behavior for invalid values, truncation cases, invalid dates, and impossible coercions where PostgreSQL differs from MySQL. +- [x] Keep unsupported SQL explicit: translate/emulate supported MySQL constructs, and return clear unsupported-SQL errors for unsupported constructs. +- [x] Do not silently swallow unsupported SQL. +- [x] Treat `FULLTEXT` and `SPATIAL` index declarations as metadata-only compatibility while keeping unsupported search/spatial query semantics explicit. +- [x] Audit regex/string-based PostgreSQL SQL translation paths that may bypass mode-aware tokenization. +- [x] Prefer tokenized translation paths where SQL mode affects parsing. + +## CI And PR Gates + +- [x] Remove `continue-on-error: true` from PostgreSQL and e2e jobs; failures should fail the PR once the expected failures are fixed or explicitly skipped. +- [x] Ensure any temporary PR skip logic cannot become a permanent default-branch skip after merge. +- [x] Keep end-to-end workflows running on default-branch pushes, not only pull requests. +- [x] If e2e jobs are skipped on PRs, make the skip condition explicit, documented, and limited to the intended event/path/label. +- [x] Keep PostgreSQL PHPUnit as a required, non-optional CI lane rather than a best-effort signal. +- [x] Keep the PR progress summary informational only; it must not hide failing PostgreSQL jobs. + +## WP-CLI + +- [x] Avoid relying on WP-CLI for PostgreSQL environment install/reset paths that assume a MySQL connection. +- [x] Generate PostgreSQL `wp-config.php` and `wp-tests-config.php` directly for WordPress PHPUnit runs. +- [x] Preserve PostgreSQL `DB_ENGINE` and `DATABASE_ENGINE` constants in both runtime and test configs. +- [x] Keep a small WP-CLI smoke check after config generation so WP-CLI still loads the PostgreSQL adapter and reports the expected constants. +- [x] Ensure PostgreSQL Docker PHP and CLI images both install and enable `pdo_pgsql`. + +## DDL And Unsupported SQL + +- [x] Translate supported `CREATE TABLE ... [AS] SELECT` forms for PostgreSQL. +- [x] Store MySQL-facing metadata for translated `CREATE TABLE ... [AS] SELECT` result tables. +- [x] Reject `CREATE TABLE ... [AS] SELECT` variants that mix unsupported table definitions, constraints, indexes, or MySQL-only options. +- [x] Return explicit unsupported-SQL errors for unsupported MySQL DDL instead of swallowing or silently passing through incompatible SQL. +- [x] Translate/emulate the supported constructs identified in this work, including metadata-only `FULLTEXT` and `SPATIAL` index declarations, while unsupported search/spatial query semantics remain explicit errors. +- [x] Tolerate MySQL `FIRST`/`AFTER ` placement suffixes inside parenthesized `ALTER TABLE ... ADD (...)` column batches while preserving explicit errors for malformed placement. +- [x] Tolerate supported MySQL table/storage options in `ALTER TABLE` with either `OPTION=value` or `OPTION value` spelling as PostgreSQL no-ops. +- [x] Emulate the common plugin upsert side effect `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` for deterministic single-row AUTO_INCREMENT self-assignments. +- [x] Harden `ON DUPLICATE KEY UPDATE` expression assignments so resolved current-row columns and `VALUES(column)` work, while unknown column references fail before backend execution. +- [x] Support safe literal-argument `COUNT(...)` scalar subqueries inside PostgreSQL `ON DUPLICATE KEY UPDATE` assignments. +- [x] Replay deterministic multi-row `ON DUPLICATE KEY UPDATE` batches per row when each row needs a different unique-key arbiter. +- [x] Fail closed for unsupported bounded single-table `UPDATE ... ORDER BY/LIMIT` shapes before backend execution. +- [x] Fail closed for PostgreSQL `ALTER TABLE ... DROP CONSTRAINT` when the generic name is missing, ambiguous across constraint metadata, or only names a non-unique index. + +## Runtime And Metadata Parity + +- [x] Emulate MySQL session identity runtime functions for PostgreSQL: `CURRENT_USER`, `CURRENT_USER()`, `USER()`, `SESSION_USER()`, and `SYSTEM_USER()`. +- [x] Emulate zero-argument `CONNECTION_ID()` using the same synthetic session ID exposed by `SHOW PROCESSLIST` and `information_schema.processlist`. +- [x] Emulate zero-argument `LAST_INSERT_ID()` without exact-query caching, so repeated calls reflect mutable insert state. +- [x] Emulate narrow standalone `LAST_INSERT_ID(expr)` scalar `SELECT` assignments for non-negative integer literals, and fail closed for table-backed, embedded, nonliteral, negative, and overflow forms. +- [x] Emulate zero-argument `ROW_COUNT()` without exact-query caching, including DML affected-row values and result-set `-1` semantics. +- [x] Translate MySQL `COALESCE()` as a common runtime function for PostgreSQL expression paths. +- [x] Fail closed for unsupported MySQL runtime function forms such as unsupported `LAST_INSERT_ID(expr)` shapes, `CURRENT_USER(expr)`, `USER(expr)`, `ROW_COUNT(expr)`, and `UUID()`. +- [x] Emulate `group_concat_max_len` as MySQL session state for `SET`, `SELECT @@...`, and `SHOW VARIABLES`, while keeping global and expression forms explicit errors. +- [x] Enforce `group_concat_max_len` for supported `GROUP_CONCAT(expr [ORDER BY ...] [SEPARATOR ...])` translations and fail closed for unsupported `GROUP_CONCAT` shapes. +- [x] Synthesize direct `information_schema.TABLES.AUTO_INCREMENT` values with schema-aware lookup instead of assuming only `public`. +- [x] Expose direct `information_schema.plugins` as an empty queryable relation with MySQL-compatible columns. +- [x] Expose direct privilege/security `information_schema` relations (`user_privileges`, `schema_privileges`, `table_privileges`, `column_privileges`, `applicable_roles`, `administrable_role_authorizations`, and `enabled_roles`) as empty queryable relations with MySQL-compatible columns. +- [x] Expose direct MySQL `information_schema` role grant relations (`role_table_grants`, `role_column_grants`, and `role_routine_grants`) as empty queryable relations with MySQL-compatible columns. +- [x] Emulate `ROW_COUNT()` after failed backend statements and explicit unsupported-SQL errors. +- [x] Decide whether to expose further MySQL `information_schema` role grant tables beyond the currently supported relations and empty routine/view/trigger/parameter/privilege/security shims; unsupported relations continue to fail explicitly. +- [x] Preserve derivable numeric and time `DATE_FORMAT()` parts for zero or partial-zero literal dates while keeping calendar-dependent specifiers conservative. +- [x] Preserve derivable numeric and time `DATE_FORMAT()` parts for zero or partial-zero dates when the format mask is a runtime expression. +- [x] Support bounded `SHOW ... WHERE` predicates using `BINARY` string comparison and `LIKE ... ESCAPE`. +- [x] Emulate `SHOW PLUGINS` as an empty MySQL-shaped metadata result with supported `LIKE` and bounded `WHERE` filters. +- [x] Keep `FOUND_ROWS()` state accurate after empty static metadata result sets such as `SHOW PLUGINS`. +- [x] Route explicit main database-qualified application-table writes and table administration after `USE information_schema` while keeping unqualified `information_schema` writes blocked. +- [x] Support PostgreSQL `TIMESTAMPADD()` composite MySQL interval literal units with the same safe interval-component translation used by `DATE_ADD()`/`DATE_SUB()`, while keeping dynamic or malformed composite values explicit unsupported errors. +- [x] Support exact-match `BINARY` predicates in direct PostgreSQL `information_schema` SELECT rewrites without sending raw MySQL `BINARY` syntax to the backend. +- [x] Fail closed for `ALTER TABLE ... DROP CHECK` and `DROP FOREIGN KEY` when MySQL metadata has no matching constraint, and resolve `DROP CONSTRAINT` metadata with the table's schema. +- [x] Support renderable MySQL timestamp runtime functions such as `NOW()` and `CURRENT_TIMESTAMP()` in `ON DUPLICATE KEY UPDATE` assignments while keeping unsupported forms explicit errors. +- [x] Translate MySQL `CHECK (json_valid(...))` constraints to PostgreSQL JSON validation for backend DDL while preserving MySQL-facing metadata and SQL `NULL` CHECK semantics. +- [x] Emulate runtime `JSON_VALID(...)` for PostgreSQL queries with MySQL-compatible `NULL`/`0`/`1` results while keeping unsupported arities explicit errors. +- [x] Support MySQL `DEFAULT(column)` assignments in `ON DUPLICATE KEY UPDATE` using stored MySQL column metadata while keeping unknown columns explicit errors. +- [x] Support `CREATE TABLE ... LIKE` for PostgreSQL by copying stored MySQL-facing columns, indexes, checks, defaults, comments, and temporary-table metadata while keeping missing sources explicit errors. + +## Tests + +- [x] Port relevant SQLite SQL mode tests to PostgreSQL. +- [x] Add PostgreSQL tests for every supported `SET sql_mode` syntax. +- [x] Add PostgreSQL tests for SQL mode reporting through `SELECT @@...` and `SHOW VARIABLES`. +- [x] Add lexer tests proving `ANSI_QUOTES` changes double-quoted tokens from string literals to identifiers. +- [x] Add PostgreSQL translation tests for double-quoted identifiers under `ANSI_QUOTES`. +- [x] Add PostgreSQL tests proving double-quoted values remain string literals without `ANSI_QUOTES`. +- [x] Add tests for `NO_BACKSLASH_ESCAPES`, `PIPES_AS_CONCAT`, `IGNORE_SPACE`, and `HIGH_NOT_PRECEDENCE` parity. +- [x] Add PostgreSQL tests for `NO_AUTO_VALUE_ON_ZERO` insert behavior. +- [x] Add PostgreSQL tests for zero-date and zero-in-date behavior in strict and non-strict modes. +- [x] Add wpdb adapter tests for no-argument `set_sql_mode()` parity with SQLite/core. +- [x] Add wpdb adapter regression coverage proving no-argument `set_sql_mode()` permits WordPress zero datetime inserts after filtering core-incompatible modes. +- [x] Add CI assertions or workflow checks proving PostgreSQL/e2e jobs are not best-effort and not skipped on default-branch pushes. +- [x] Add/keep WP-CLI smoke tests for PostgreSQL config loading without using WP-CLI for MySQL-specific install/reset steps. +- [x] Add PostgreSQL tests for supported `CREATE TABLE ... [AS] SELECT` translations and unsupported variant errors. +- [x] Add PostgreSQL runtime-function tests for emulated session identity, `CONNECTION_ID()`, `LAST_INSERT_ID()`, and fail-closed unsupported forms. +- [x] Add PostgreSQL tests for `ROW_COUNT()` mutable state, `group_concat_max_len`, parenthesized `ALTER TABLE ... ADD (...)` placement, direct `information_schema.TABLES.AUTO_INCREMENT`, and `LAST_INSERT_ID(id)` upsert side effects. +- [x] Add PostgreSQL tests for standalone `LAST_INSERT_ID(expr)` assignment behavior, `GROUP_CONCAT` truncation and fail-closed forms, `information_schema.plugins`, optional-equals `ALTER TABLE` options, and upsert expression column validation. +- [x] Add PostgreSQL regression tests for literal-argument `COUNT(...)` upsert scalar subquery assignments and unresolved `COUNT(column)` failures. +- [x] Add PostgreSQL tests for empty privilege/security `information_schema` relation reads, metadata columns, `USE information_schema` routing, and joins. +- [x] Add PostgreSQL tests for `ROW_COUNT()` after backend failures and explicit unsupported-SQL failures. +- [x] Add PostgreSQL tests for deterministic multi-row ambiguous upsert replay, unsupported bounded `UPDATE` fail-closed behavior, role grant `information_schema` shims, zero-date `DATE_FORMAT()` literal masks, and `SHOW WHERE` `BINARY`/`ESCAPE` filters. +- [x] Add PostgreSQL tests for empty `SHOW PLUGINS` results, stale `FOUND_ROWS()` reset behavior, and unsupported `SHOW PLUGINS` clauses. +- [x] Add PostgreSQL tests for main database-qualified writes and administration after `USE information_schema`. +- [x] Add PostgreSQL tests for `TIMESTAMPADD()` composite interval translation and dynamic/malformed composite interval fail-closed behavior before backend execution. +- [x] Add PostgreSQL regressions for generic `ALTER TABLE ... DROP CONSTRAINT` missing, ambiguous, and non-unique-index metadata cases. +- [x] Add PostgreSQL regression coverage for direct `information_schema.TABLES` `BINARY` exact-match predicates. +- [x] Add PostgreSQL regressions for missing `ALTER TABLE ... DROP CHECK` and `DROP FOREIGN KEY` metadata. +- [x] Add PostgreSQL tests for SQLite-UDF-style runtime compatibility functions (`CURDATE()`, `UTC_DATE()`, `UTC_TIME()`, `NOW()`, `DATABASE()`, `GET_LOCK()`, and related forms). +- [x] Add PostgreSQL tests for runtime `DATE_FORMAT()` masks over zero/partial-zero dates. +- [x] Add PostgreSQL tests for timestamp runtime functions inside `ON DUPLICATE KEY UPDATE` assignments. +- [x] Add PostgreSQL tests for `json_valid(...)` CHECK translation, MySQL metadata preservation, unsupported CHECK shapes, runtime `JSON_VALID(...)` emulation, and unsupported runtime arities. +- [x] Add PostgreSQL regression tests for `DEFAULT(column)` assignments inside `ON DUPLICATE KEY UPDATE`. +- [x] Add PostgreSQL regression tests for permanent and temporary `CREATE TABLE ... LIKE` metadata copying and missing-source fail-closed behavior. +- [x] Add PostgreSQL regression tests for metadata-only `FULLTEXT`/`SPATIAL` index declarations and fail-closed `MATCH ... AGAINST` search syntax. diff --git a/packages/mysql-on-sqlite/composer.json b/packages/mysql-on-sqlite/composer.json index c7ef2b417..fc2f4d8cb 100644 --- a/packages/mysql-on-sqlite/composer.json +++ b/packages/mysql-on-sqlite/composer.json @@ -1,8 +1,18 @@ { "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", + "test-postgresql": [ + "phpunit -c phpunit-postgresql.xml.dist tests/WP_PostgreSQL_Connection_Tests.php", + "phpunit -c phpunit-postgresql.xml.dist tests/WP_PostgreSQL_Create_Table_Translator_Tests.php", + "phpunit -c phpunit-postgresql.xml.dist tests/WP_PostgreSQL_DB_Tests.php", + "phpunit -c phpunit-postgresql.xml.dist tests/WP_PostgreSQL_Driver_RegExp_Tests.php", + "phpunit -c phpunit-postgresql.xml.dist --filter test_real_pgsql tests/WP_PostgreSQL_Driver_Tests.php", + "phpunit -c phpunit-postgresql.xml.dist tests/WP_PostgreSQL_Install_Functions_Tests.php" + ], "bench-lexer": [ "@php tests/tools/run-lexer-benchmark.php", "@php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing tests/tools/run-lexer-benchmark.php" diff --git a/packages/mysql-on-sqlite/phpunit-postgresql.xml.dist b/packages/mysql-on-sqlite/phpunit-postgresql.xml.dist new file mode 100644 index 000000000..80a293861 --- /dev/null +++ b/packages/mysql-on-sqlite/phpunit-postgresql.xml.dist @@ -0,0 +1,27 @@ + + + + + + + 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/phpunit.xml.dist b/packages/mysql-on-sqlite/phpunit.xml.dist index a41280c83..20523535c 100644 --- a/packages/mysql-on-sqlite/phpunit.xml.dist +++ b/packages/mysql-on-sqlite/phpunit.xml.dist @@ -2,6 +2,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/9.2/phpunit.xsd" bootstrap="tests/bootstrap.php" + defaultTestSuite="default" backupGlobals="false" colors="true" beStrictAboutTestsThatDoNotTestAnything="true" @@ -15,10 +16,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..7686c1649 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -43,3 +43,7 @@ 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/trait-wp-postgresql-driver-rewrite-rules.php'; +require_once __DIR__ . '/postgresql/class-wp-postgresql-driver.php'; diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php index d6ee9970e..4dfef44fd 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php @@ -32,6 +32,7 @@ class WP_MySQL_Lexer { const SQL_MODE_PIPES_AS_CONCAT = 2; const SQL_MODE_IGNORE_SPACE = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES = 8; + const SQL_MODE_ANSI_QUOTES = 16; /** * Character masks for frequently used character classes. @@ -2209,6 +2210,8 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_IGNORE_SPACE; } elseif ( 'NO_BACKSLASH_ESCAPES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; + } elseif ( 'ANSI_QUOTES' === $sql_mode ) { + $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; } } } @@ -2951,7 +2954,9 @@ private function read_quoted_text(): ?int { if ( '`' === $quote ) { return self::BACK_TICK_QUOTED_ID; } elseif ( '"' === $quote ) { - return self::DOUBLE_QUOTED_TEXT; + return $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) + ? self::BACK_TICK_QUOTED_ID + : self::DOUBLE_QUOTED_TEXT; } else { return self::SINGLE_QUOTED_TEXT; } diff --git a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php index def8ca3fa..ad4af2343 100644 --- a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php @@ -1,3 +1,9 @@ pdo = $options['pdo']; + } else { + $dsn = isset( $options['dsn'] ) ? (string) $options['dsn'] : self::build_dsn( $options ); + $user = isset( $options['user'] ) ? (string) $options['user'] : null; + $password = isset( $options['password'] ) ? (string) $options['password'] : null; + $pdo_class = PHP_VERSION_ID >= 80400 && class_exists( 'PDO\Pgsql' ) ? PDO\Pgsql::class : PDO::class; + + $this->pdo = new $pdo_class( $dsn, $user, $password ); + } + + if ( 'pgsql' !== $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + throw new InvalidArgumentException( 'WP_PostgreSQL_Connection requires a pgsql PDO driver.' ); + } + + $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 ) + ) { + $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 ); + } + + /** + * 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 ( ! $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..e6012629f --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php @@ -0,0 +1,3853 @@ + 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + ); + + /** + * Reusable MySQL grammar. + * + * @var WP_Parser_Grammar|null + */ + private static $mysql_grammar = null; + + /** + * SQL modes active while tokenizing CREATE TABLE statements. + * + * @var string[] + */ + private $sql_modes; + + /** + * Whether metadata-only index options are allowed while parsing ALTER fragments. + * + * @var bool + */ + private $allow_metadata_only_index_options; + + /** + * Constructor. + * + * @param string[] $sql_modes Active SQL modes. + * @param bool $allow_metadata_only_index_options Whether metadata-only index options are allowed. + */ + public function __construct( array $sql_modes = array(), bool $allow_metadata_only_index_options = false ) { + $this->sql_modes = $sql_modes; + $this->allow_metadata_only_index_options = $allow_metadata_only_index_options; + } + + /** + * Parse and translate all CREATE TABLE statements in a schema string. + * + * @param string $sql MySQL schema SQL. + * @return string[] PostgreSQL DDL statements. + */ + public function translate_schema( string $sql ): array { + $parser = $this->create_parser( $sql ); + $statements = array(); + + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( ! $ast || ! $ast->has_child() ) { + continue; + } + + foreach ( $this->translate( $ast ) as $statement ) { + $statements[] = $statement; + } + } + + return $statements; + } + + /** + * Extract original MySQL CREATE TABLE statements from a schema string. + * + * @param string $sql MySQL schema SQL. + * @return string[] MySQL CREATE TABLE statements. + */ + public function extract_create_table_statements( 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; + } + + $create_table = $this->get_create_table_node( $ast ); + if ( ! $create_table ) { + continue; + } + + $statements[] = rtrim( substr( $sql, $ast->get_start(), $ast->get_length() ), " \t\n\r\0\x0B;" ); + } + + return $statements; + } + + /** + * Get PostgreSQL helper type statements needed by a MySQL schema. + * + * @param string $sql MySQL schema SQL. + * @return string[] PostgreSQL helper statements. + */ + public function get_postgresql_mysql_helper_type_statements( string $sql ): array { + $statements = array(); + + foreach ( $this->extract_schema_metadata( $sql, false ) as $table ) { + foreach ( $table['columns'] as $column ) { + $column_type = trim( (string) ( $column['type'] ?? '' ) ); + if ( preg_match( '/^enum\(.+\)$/i', $column_type ) ) { + $type_name = $this->get_postgresql_mysql_enum_type( $column_type ); + if ( isset( $statements[ $type_name ] ) ) { + continue; + } + + $statements[ $type_name ] = array( + sprintf( + 'DO $wp_mysql_enum_type$ +BEGIN + CREATE TYPE %s AS ENUM (%s); +EXCEPTION WHEN duplicate_object THEN + NULL; +END; +$wp_mysql_enum_type$', + $this->quote_identifier( $type_name ), + implode( ', ', $this->get_postgresql_mysql_enum_labels( $column_type ) ) + ), + ); + continue; + } + + if ( preg_match( '/^set\(.+\)$/i', $column_type ) ) { + $type_name = $this->get_postgresql_mysql_set_domain_type( $column_type ); + if ( isset( $statements[ $type_name ] ) ) { + continue; + } + + $statements[ $type_name ] = array( + sprintf( + 'DO $wp_mysql_set_domain$ +BEGIN + CREATE DOMAIN %s AS text; +EXCEPTION WHEN duplicate_object THEN + NULL; +END; +$wp_mysql_set_domain$', + $this->quote_identifier( $type_name ) + ), + sprintf( + 'COMMENT ON DOMAIN %s IS %s', + $this->quote_identifier( $type_name ), + $this->quote_string_literal( self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX . base64_encode( $column_type ) ) + ), + ); + } + } + } + + $flattened = array(); + foreach ( $statements as $type_statements ) { + foreach ( $type_statements as $statement ) { + $flattened[] = $statement; + } + } + + return $flattened; + } + + /** + * Translate a parsed CREATE TABLE statement. + * + * @param WP_Parser_Node $create_statement Parsed query/createStatement/createTable node. + * @return string[] PostgreSQL DDL statements. + */ + public function translate( WP_Parser_Node $create_statement ): array { + $create_table = $this->get_create_table_node( $create_statement ); + if ( ! $create_table ) { + throw new InvalidArgumentException( 'Only CREATE TABLE statements are supported by the PostgreSQL DDL translator.' ); + } + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + throw new InvalidArgumentException( 'CREATE TABLE ... AS SELECT is not supported by the PostgreSQL DDL translator.' ); + } + + $table_name = $this->get_table_name( $create_table ); + $if_not_exists = $create_table->has_child_node( 'ifNotExists' ); + $column_types = $this->get_create_table_column_types( $element_list ); + $columns = array(); + $constraints = array(); + $indexes = array(); + $foreign_key_ordinal = 1; + $foreign_key_names = array(); + $check_ordinal = 1; + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $columns[] = $this->translate_column_definition( $column_definition, $table_name, $foreign_key_ordinal, $foreign_key_names, $check_ordinal ); + continue; + } + + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( ! $table_constraint ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE element.' ); + } + + if ( $table_constraint->get_first_child_node( 'checkConstraint' ) ) { + $check_sql = $this->translate_check_constraint_definition( $table_constraint, $table_name, $check_ordinal ); + if ( null !== $check_sql ) { + $constraints[] = $check_sql; + } + continue; + } + + if ( $this->is_table_foreign_key_constraint( $table_constraint ) ) { + $constraints[] = $this->translate_table_foreign_key_constraint_definition( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + continue; + } + + $this->validate_mysql_table_constraint_index_options( $table_constraint ); + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $constraints[] = 'PRIMARY KEY (' . implode( ', ', $this->quote_key_parts( $table_constraint ) ) . ')'; + continue; + } + + $index = $this->translate_secondary_index( $table_constraint, $table_name, $if_not_exists, $column_types ); + if ( null !== $index ) { + $indexes[] = $index; + } + } + + $definitions = array_merge( $columns, $constraints ); + if ( empty( $definitions ) ) { + throw new InvalidArgumentException( 'CREATE TABLE statement does not define any columns.' ); + } + + $create_sql = sprintf( + 'CREATE %sTABLE %s%s (%s%s%s)', + $create_table->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ) ? 'TEMPORARY ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name ), + "\n ", + implode( ",\n ", $definitions ), + "\n" + ); + + return array_merge( array( $create_sql ), $indexes ); + } + + /** + * Extract MySQL charset metadata from CREATE TABLE statements. + * + * @param string $sql MySQL schema SQL. + * @return array[] Table metadata. + */ + public function extract_schema_metadata( string $sql, bool $include_indexes = false ): array { + $parser = $this->create_parser( $sql ); + $tables = array(); + + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( ! $ast || ! $ast->has_child() ) { + continue; + } + + $create_table = $this->get_create_table_node( $ast ); + if ( $create_table ) { + $tables[] = $this->extract_create_table_metadata( $create_table, $include_indexes ); + } + } + + return $tables; + } + + /** + * Create a parser for a MySQL SQL string. + * + * @param string $sql MySQL SQL. + * @return WP_MySQL_Parser Parser instance. + */ + private function create_parser( string $sql ): WP_MySQL_Parser { + $sql = $this->normalize_parser_unsafe_long_character_aliases( $sql ); + $lexer = new WP_MySQL_Lexer( $sql, 80038, $this->sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + return new WP_MySQL_Parser( $this->get_mysql_grammar(), $tokens ); + } + + /** + * Normalize LONG CHAR aliases that can make the parser fail to converge. + * + * SQLite treats these as MEDIUMTEXT metadata, same as LONG VARCHAR. Rewrite + * only the tokenized type alias so parsing remains bounded while downstream + * metadata normalization still sees a LONG-prefixed text alias. + * + * @param string $sql MySQL SQL. + * @return string SQL with parser-safe LONG character aliases. + */ + private function normalize_parser_unsafe_long_character_aliases( string $sql ): string { + $lexer = new WP_MySQL_Lexer( $sql, 80038, $this->sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + $rewritten = ''; + $cursor = 0; + $changed = false; + for ( $i = 0; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; ++$i ) { + if ( + WP_MySQL_Lexer::LONG_SYMBOL !== $tokens[ $i ]->id + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::CHAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $start_token = $tokens[ $i ]; + $end_token = $tokens[ $i + 1 ]; + if ( isset( $tokens[ $i + 2 ] ) && WP_MySQL_Lexer::VARYING_SYMBOL === $tokens[ $i + 2 ]->id ) { + $end_token = $tokens[ $i + 2 ]; + $i = $i + 2; + } else { + ++$i; + } + + $start = $start_token->start; + $end = $end_token->start + $end_token->length; + $rewritten .= substr( $sql, $cursor, $start - $cursor ) . 'LONG VARCHAR'; + $cursor = $end; + $changed = true; + } + + if ( ! $changed ) { + return $sql; + } + + return $rewritten . substr( $sql, $cursor ); + } + + /** + * Get the parser grammar. + * + * @return WP_Parser_Grammar MySQL grammar. + */ + private function get_mysql_grammar(): WP_Parser_Grammar { + if ( null === self::$mysql_grammar ) { + self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH ); + } + + return self::$mysql_grammar; + } + + /** + * Locate a createTable node from accepted AST entry points. + * + * @param WP_Parser_Node $node Parsed node. + * @return WP_Parser_Node|null createTable node. + */ + private function get_create_table_node( WP_Parser_Node $node ): ?WP_Parser_Node { + if ( 'createTable' === $node->rule_name ) { + return $node; + } + + if ( 'createStatement' === $node->rule_name ) { + return $node->get_first_child_node( 'createTable' ); + } + + $simple_statement = $node->get_first_child_node( 'simpleStatement' ); + if ( $simple_statement ) { + $create_statement = $simple_statement->get_first_child_node( 'createStatement' ); + return $create_statement ? $create_statement->get_first_child_node( 'createTable' ) : null; + } + + return null; + } + + /** + * Translate a MySQL column definition. + * + * @param WP_Parser_Node $column_definition Column definition node. + * @param string $table_name Table name. + * @param int $foreign_key_ordinal Next inline foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @param int $check_ordinal Next inline CHECK ordinal. + * @return string PostgreSQL column definition. + */ + private function translate_column_definition( WP_Parser_Node $column_definition, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names, int &$check_ordinal ): string { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + if ( ! $field_definition ) { + throw new InvalidArgumentException( 'Column definition is missing a field definition.' ); + } + if ( $this->has_unsupported_mysql_column_attribute( $field_definition ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE column attribute.' ); + } + + $data_type = $field_definition->get_first_child_node( 'dataType' ); + $is_serial = $this->is_serial_data_type( $data_type ); + $is_auto_increment = $is_serial || null !== $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ); + $mysql_column_type = $this->get_mysql_column_type( $data_type, $field_definition ); + $has_not_null = false; + $has_primary_key = false; + $has_unique_key = false; + $parts = array( + $this->quote_identifier( $name ), + $this->translate_data_type( $data_type, $is_auto_increment, $mysql_column_type ), + ); + + $column_attributes = $field_definition->get_child_nodes( 'columnAttribute' ); + foreach ( $column_attributes as $attribute_index => $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + continue; + } + + if ( $this->is_not_null_column_attribute( $attribute ) ) { + $has_not_null = true; + $parts[] = 'NOT NULL'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $has_primary_key = true; + $parts[] = 'PRIMARY KEY'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ) { + $has_unique_key = true; + $parts[] = 'CONSTRAINT ' . $this->quote_identifier( $name ) . ' UNIQUE'; + continue; + } + + $check_constraint = $attribute->get_first_child_node( 'checkConstraint' ); + if ( $check_constraint ) { + $check_sql = $this->translate_check_constraint_definition( + $attribute, + $table_name, + $check_ordinal, + $this->is_followed_by_not_enforced_column_attribute( $column_attributes, $attribute_index ) + ); + if ( null !== $check_sql ) { + $parts[] = $check_sql; + } + continue; + } + + if ( $attribute->get_first_child_node( 'constraintEnforcement' ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $parts[] = $this->translate_default_attribute( $attribute, $data_type ); + } + } + + if ( $is_serial ) { + if ( ! $has_not_null && ! $has_primary_key ) { + $parts[] = 'NOT NULL'; + } + + if ( ! $has_primary_key && ! $has_unique_key ) { + $parts[] = 'CONSTRAINT ' . $this->quote_identifier( $name ) . ' UNIQUE'; + } + } + + $references = $this->get_inline_references_node( $column_definition ); + if ( $references ) { + $constraint_name = $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ); + $parts[] = 'CONSTRAINT ' . $this->quote_identifier( $constraint_name ) . ' ' . $this->translate_inline_references( $references ); + } + + $check_constraint = $this->get_inline_check_constraint_node( $column_definition ); + if ( $check_constraint ) { + $check_sql = $this->translate_check_constraint_definition( $check_constraint, $table_name, $check_ordinal ); + if ( null !== $check_sql ) { + $parts[] = $check_sql; + } + } + + return implode( ' ', $parts ); + } + + /** + * Check whether a field definition has an explicit NOT NULL attribute. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether the column is explicitly NOT NULL. + */ + private function has_not_null_column_attribute( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $this->is_not_null_column_attribute( $attribute ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a column attribute is NOT NULL. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return bool Whether the attribute is NOT NULL. + */ + private function is_not_null_column_attribute( WP_Parser_Node $attribute ): bool { + if ( $attribute->get_first_child_node( 'constraintEnforcement' ) ) { + return false; + } + + return null !== $attribute->get_first_descendant_token( WP_MySQL_Lexer::NOT_SYMBOL ) + && null !== $attribute->get_first_descendant_token( WP_MySQL_Lexer::NULL_SYMBOL ); + } + + /** + * Get MySQL column types keyed by lowercase column name for DDL index translation. + * + * @param WP_Parser_Node $element_list CREATE TABLE element list. + * @return array Column types keyed by lowercase name. + */ + private function get_create_table_column_types( WP_Parser_Node $element_list ): array { + $column_types = array(); + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( ! $column_definition ) { + continue; + } + + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + if ( ! $field_definition ) { + throw new InvalidArgumentException( 'Column definition is missing a field definition.' ); + } + + $data_type = $field_definition->get_first_child_node( 'dataType' ); + + $column_types[ strtolower( $name ) ] = $this->get_mysql_column_type( $data_type, $field_definition ); + } + + return $column_types; + } + + /** + * Check whether a MySQL column attribute changes semantics PostgreSQL does not emulate. + * + * @param WP_Parser_Node $node Field definition or column attribute node. + * @return bool Whether the attribute is unsupported. + */ + private function has_unsupported_mysql_column_attribute( WP_Parser_Node $node ): bool { + return null !== $node->get_first_descendant_token( WP_MySQL_Lexer::GENERATED_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::COLUMN_FORMAT_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::STORAGE_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::VISIBLE_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::INVISIBLE_SYMBOL ); + } + + /** + * Get a MySQL inline REFERENCES node from a field definition. + * + * @param WP_Parser_Node $column_definition Column definition node. + * @return WP_Parser_Node|null REFERENCES node. + */ + private function get_inline_references_node( WP_Parser_Node $column_definition ): ?WP_Parser_Node { + $check_or_references = $column_definition->get_first_child_node( 'checkOrReferences' ); + return $check_or_references ? $check_or_references->get_first_child_node( 'references' ) : null; + } + + /** + * Get a MySQL inline CHECK node after a column definition. + * + * @param WP_Parser_Node|null $column_definition Column definition node. + * @return WP_Parser_Node|null Inline CHECK node. + */ + private function get_inline_check_constraint_node( ?WP_Parser_Node $column_definition ): ?WP_Parser_Node { + $check_or_references = $column_definition ? $column_definition->get_first_child_node( 'checkOrReferences' ) : null; + return $check_or_references ? $check_or_references->get_first_child_node( 'checkConstraint' ) : null; + } + + /** + * Translate a MySQL CHECK constraint to PostgreSQL. + * + * @param WP_Parser_Node $node Node containing a checkConstraint child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @param bool $not_enforced Whether enforcement was represented by a following sibling node. + * @return string PostgreSQL CHECK constraint SQL. + */ + private function translate_check_constraint_definition( WP_Parser_Node $node, string $table_name, int &$check_ordinal, bool $not_enforced = false ): string { + $check_constraint = 'checkConstraint' === $node->rule_name ? $node : $node->get_first_child_node( 'checkConstraint' ); + if ( ! $check_constraint ) { + throw new InvalidArgumentException( 'Expected CHECK constraint node.' ); + } + + $constraint_name = $this->get_check_constraint_name( $node, $table_name, $check_ordinal ); + $expression = $this->translate_check_constraint_expression( $check_constraint ); + if ( $not_enforced || $this->is_check_constraint_not_enforced( $node ) ) { + $expression = 'true'; + } + + return sprintf( + 'CONSTRAINT %s CHECK (%s)', + $this->quote_identifier( $constraint_name ), + $expression + ); + } + + /** + * Get the MySQL-compatible CHECK constraint name. + * + * @param WP_Parser_Node $node Node containing an optional constraintName child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @return string Constraint name. + */ + private function get_check_constraint_name( WP_Parser_Node $node, string $table_name, int &$check_ordinal ): string { + $constraint_name = $node->get_first_child_node( 'constraintName' ); + if ( $constraint_name ) { + return $this->get_identifier_value( $constraint_name->get_first_child_node( 'identifier' ) ); + } + + return $table_name . '_chk_' . $check_ordinal++; + } + + /** + * Check whether a CHECK constraint is explicitly NOT ENFORCED. + * + * @param WP_Parser_Node $node Node to inspect. + * @return bool Whether the node contains NOT ENFORCED. + */ + private function is_check_constraint_not_enforced( WP_Parser_Node $node ): bool { + $enforcement = $node->get_first_child_node( 'constraintEnforcement' ); + return $enforcement && $enforcement->has_child_token( WP_MySQL_Lexer::NOT_SYMBOL ); + } + + /** + * Check whether a column CHECK attribute is followed by NOT ENFORCED. + * + * @param WP_Parser_Node[] $attributes Column attribute nodes. + * @param int $attribute_index Current CHECK attribute index. + * @return bool Whether the following attribute is NOT ENFORCED. + */ + private function is_followed_by_not_enforced_column_attribute( array $attributes, int $attribute_index ): bool { + $next_attribute = $attributes[ $attribute_index + 1 ] ?? null; + return $next_attribute instanceof WP_Parser_Node && $this->is_check_constraint_not_enforced( $next_attribute ); + } + + /** + * Translate a MySQL CHECK expression to PostgreSQL SQL. + * + * @param WP_Parser_Node $check_constraint CHECK constraint node. + * @return string PostgreSQL expression SQL. + */ + private function translate_check_constraint_expression( WP_Parser_Node $check_constraint ): string { + return $this->render_check_constraint_expression( $check_constraint, true ); + } + + /** + * Render a MySQL CHECK expression. + * + * @param WP_Parser_Node $check_constraint CHECK constraint node. + * @param bool $for_postgresql Whether to translate MySQL-only functions for backend execution. + * @return string CHECK expression SQL. + */ + private function render_check_constraint_expression( WP_Parser_Node $check_constraint, bool $for_postgresql ): string { + $expression = $check_constraint->get_first_descendant_node( 'expr' ); + if ( ! $expression ) { + throw new InvalidArgumentException( 'CHECK constraint is missing an expression.' ); + } + + return $this->translate_check_constraint_tokens( $expression->get_descendant_tokens(), $for_postgresql ); + } + + /** + * Translate CHECK expression tokens. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param bool $for_postgresql Whether to translate MySQL-only functions for backend execution. + * @return string PostgreSQL SQL. + */ + private function translate_check_constraint_tokens( array $tokens, bool $for_postgresql ): string { + $sql = ''; + $previous_token = null; + + for ( $position = 0; $position < count( $tokens ); ++$position ) { + $token = $tokens[ $position ]; + $fragment = null; + if ( $for_postgresql ) { + $json_valid = $this->translate_json_valid_check_constraint_function( $tokens, $position ); + if ( null !== $json_valid ) { + $fragment = $json_valid['sql']; + $position = $json_valid['position']; + } + } + + if ( null === $fragment ) { + $fragment = $this->translate_check_constraint_token( $token ); + } + if ( '' === $fragment ) { + continue; + } + + if ( '' !== $sql && $this->check_constraint_tokens_need_space( $previous_token, $token ) ) { + $sql .= ' '; + } + + $sql .= $fragment; + $previous_token = $tokens[ $position ]; + } + + return $sql; + } + + /** + * Translate MySQL json_valid(expr) CHECK calls to PostgreSQL JSON validation. + * + * PostgreSQL has JSON casts but no MySQL/SQLite json_valid() function. Casting + * invalid JSON fails the statement, which keeps invalid data out instead of + * emitting unsupported raw function SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $position Current token position. + * @return array{sql: string, position: int}|null Translation data, or null when this is not json_valid(). + */ + private function translate_json_valid_check_constraint_function( array $tokens, int $position ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_json_valid_identifier_token( $tokens[ $position ] ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close_position = $this->get_check_constraint_parenthesized_end( $tokens, $position + 1 ); + if ( null === $close_position ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $arguments = $this->split_check_constraint_function_arguments( $tokens, $position + 2, $close_position ); + if ( 1 !== count( $arguments ) || $arguments[0]['start'] >= $arguments[0]['end'] ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $argument_sql = $this->translate_check_constraint_tokens( + array_slice( $tokens, $arguments[0]['start'], $arguments[0]['end'] - $arguments[0]['start'] ), + true + ); + + return array( + 'sql' => sprintf( '(CASE WHEN %1$s IS NULL THEN NULL ELSE (CAST(%1$s AS jsonb) IS NOT NULL) END)', $argument_sql ), + 'position' => $close_position, + ); + } + + /** + * Check whether a token names json_valid. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a json_valid identifier. + */ + private function is_json_valid_identifier_token( WP_MySQL_Token $token ): bool { + return in_array( $token->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + && 'json_valid' === strtolower( $token->get_value() ); + } + + /** + * Find the closing parenthesis for a CHECK function call. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $open_position Opening parenthesis position. + * @return int|null Closing parenthesis position, or null when malformed. + */ + private function get_check_constraint_parenthesized_end( array $tokens, int $open_position ): ?int { + $depth = 0; + for ( $position = $open_position; $position < count( $tokens ); ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + --$depth; + if ( 0 === $depth ) { + return $position; + } + } + + return null; + } + + /** + * Split top-level CHECK function arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $start First argument token position. + * @param int $end Closing parenthesis position, exclusive. + * @return array Argument token ranges. + */ + private function split_check_constraint_function_arguments( array $tokens, int $start, int $end ): array { + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $arguments[] = array( + 'start' => $argument_start, + 'end' => $position, + ); + $argument_start = $position + 1; + } + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $end, + ); + + return $arguments; + } + + /** + * Translate one CHECK expression token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string PostgreSQL SQL fragment. + */ + private function translate_check_constraint_token( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->quote_identifier( $token->get_value() ); + } + + return $token->get_bytes(); + } + + /** + * Decide whether two CHECK expression tokens need a separating space. + * + * @param WP_MySQL_Token|null $previous Previous token, or null. + * @param WP_MySQL_Token $current Current token. + * @return bool Whether to add a space. + */ + private function check_constraint_tokens_need_space( ?WP_MySQL_Token $previous, WP_MySQL_Token $current ): bool { + if ( null === $previous ) { + return false; + } + + if ( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $current->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $previous->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous->id + ) { + return false; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $current->id + && $this->is_check_constraint_identifier_like_token( $previous ) + ) { + return false; + } + + return true; + } + + /** + * Check whether a CHECK token is identifier-like. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is identifier-like. + */ + private function is_check_constraint_identifier_like_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::IDENTIFIER, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + ), + true + ); + } + + /** + * Translate inline MySQL REFERENCES syntax. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return string PostgreSQL REFERENCES clause. + */ + private function translate_inline_references( WP_Parser_Node $references ): string { + $reference = $this->extract_inline_reference_metadata( $references ); + $table_sql = $this->quote_table_reference( + $reference['referenced_schema'], + $reference['referenced_table'] + ); + $columns = array_map( array( $this, 'quote_identifier' ), $reference['referenced_columns'] ); + + $sql = sprintf( + 'REFERENCES %s (%s)', + $table_sql, + implode( ', ', $columns ) + ); + + if ( 'NO ACTION' !== $reference['delete_rule'] ) { + $sql .= ' ON DELETE ' . $reference['delete_rule']; + } + + if ( 'NO ACTION' !== $reference['update_rule'] ) { + $sql .= ' ON UPDATE ' . $reference['update_rule']; + } + + return $sql; + } + + /** + * Check whether a table constraint is a FOREIGN KEY definition. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return bool Whether the node is a FOREIGN KEY constraint. + */ + private function is_table_foreign_key_constraint( WP_Parser_Node $table_constraint ): bool { + return $table_constraint->has_child_token( WP_MySQL_Lexer::FOREIGN_SYMBOL ) + && null !== $table_constraint->get_first_child_node( 'references' ); + } + + /** + * Translate a table-level MySQL FOREIGN KEY constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string PostgreSQL FOREIGN KEY constraint SQL. + */ + private function translate_table_foreign_key_constraint_definition( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + $foreign_key = $this->extract_table_foreign_key_metadata( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + $table_sql = $this->quote_table_reference( + $foreign_key['referenced_schema'], + $foreign_key['referenced_table'] + ); + + $sql = sprintf( + 'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)', + $this->quote_identifier( $foreign_key['name'] ), + implode( ', ', array_map( array( $this, 'quote_identifier' ), $foreign_key['columns'] ) ), + $table_sql, + implode( ', ', array_map( array( $this, 'quote_identifier' ), $foreign_key['referenced_columns'] ) ) + ); + + if ( 'NO ACTION' !== $foreign_key['delete_rule'] ) { + $sql .= ' ON DELETE ' . $foreign_key['delete_rule']; + } + + if ( 'NO ACTION' !== $foreign_key['update_rule'] ) { + $sql .= ' ON UPDATE ' . $foreign_key['update_rule']; + } + + return $sql; + } + + /** + * Extract the referenced table, columns, and rules from inline REFERENCES. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return array{referenced_schema: string|null, referenced_table: string, referenced_columns: string[], update_rule: string, delete_rule: string} + */ + private function extract_inline_reference_metadata( WP_Parser_Node $references ): array { + if ( $references->has_child_token( WP_MySQL_Lexer::MATCH_SYMBOL ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $table_reference = $this->get_table_reference_parts( $references->get_first_child_node( 'tableRef' ) ); + $columns = $this->get_identifier_list_values( $references->get_first_child_node( 'identifierListWithParentheses' ) ); + if ( empty( $columns ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $rules = $this->get_inline_reference_rules( $references ); + + return array( + 'referenced_schema' => $table_reference['schema'], + 'referenced_table' => $table_reference['table'], + 'referenced_columns' => $columns, + 'update_rule' => $rules['update_rule'], + 'delete_rule' => $rules['delete_rule'], + ); + } + + /** + * Get table reference parts from a tableRef node. + * + * @param WP_Parser_Node|null $table_ref Table reference node. + * @return array{schema: string|null, table: string} + */ + private function get_table_reference_parts( ?WP_Parser_Node $table_ref ): array { + if ( ! $table_ref ) { + throw new InvalidArgumentException( 'Expected table reference node.' ); + } + + $identifiers = array(); + foreach ( $table_ref->get_descendant_nodes( 'identifier' ) as $identifier ) { + $identifiers[] = $this->get_identifier_value( $identifier ); + } + + if ( 1 === count( $identifiers ) ) { + return array( + 'schema' => null, + 'table' => $identifiers[0], + ); + } + + if ( 2 === count( $identifiers ) ) { + return array( + 'schema' => $identifiers[0], + 'table' => $identifiers[1], + ); + } + + throw new InvalidArgumentException( 'Unsupported table reference.' ); + } + + /** + * Quote a possibly schema-qualified table reference. + * + * @param string|null $schema_name Schema name, or null. + * @param string $table_name Table name. + * @return string Quoted table reference. + */ + private function quote_table_reference( ?string $schema_name, string $table_name ): string { + if ( null === $schema_name ) { + return $this->quote_identifier( $table_name ); + } + + return $this->quote_identifier( $schema_name ) . '.' . $this->quote_identifier( $table_name ); + } + + /** + * Get identifier values from an identifierListWithParentheses node. + * + * @param WP_Parser_Node|null $identifier_list Identifier list node. + * @return string[] Identifier values. + */ + private function get_identifier_list_values( ?WP_Parser_Node $identifier_list ): array { + if ( ! $identifier_list ) { + return array(); + } + + $identifiers = array(); + foreach ( $identifier_list->get_descendant_nodes( 'identifier' ) as $identifier ) { + $identifiers[] = $this->get_identifier_value( $identifier ); + } + + return $identifiers; + } + + /** + * Parse inline foreign key ON UPDATE/ON DELETE rules. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return array{update_rule: string, delete_rule: string} + */ + private function get_inline_reference_rules( WP_Parser_Node $references ): array { + $rules = array( + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ); + $seen = array(); + $tokens = $references->get_descendant_tokens(); + + for ( $position = 0; $position < count( $tokens ); ++$position ) { + if ( WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( ! isset( $tokens[ $position + 1 ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'update_rule'; + } elseif ( WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'delete_rule'; + } else { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( isset( $seen[ $rule_key ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $option_position = $position + 2; + $rules[ $rule_key ] = $this->get_inline_reference_option( $tokens, $option_position ); + $seen[ $rule_key ] = true; + $position = $option_position - 1; + } + + return $rules; + } + + /** + * Parse one inline foreign key reference option. + * + * @param WP_MySQL_Token[] $tokens REFERENCES token stream. + * @param int $position Current token position, updated on success. + * @return string Reference option. + */ + private function get_inline_reference_option( array $tokens, int &$position ): string { + if ( ! isset( $tokens[ $position ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) ) { + $rule = strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + return $rule; + } + + if ( WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET NULL'; + } + + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET DEFAULT'; + } + + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::ACTION_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + return 'NO ACTION'; + } + + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + /** + * Get the MySQL-style implicit foreign key constraint name. + * + * @param string $table_name Table name. + * @param int $ordinal Constraint ordinal. + * @return string Constraint name. + */ + private function get_implicit_foreign_key_constraint_name( string $table_name, int $ordinal ): string { + return $table_name . '_ibfk_' . $ordinal; + } + + /** + * Get a MySQL-compatible FOREIGN KEY constraint name. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string Constraint name. + */ + private function get_foreign_key_constraint_name( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + $constraint_name = $table_constraint->get_first_child_node( 'constraintName' ); + if ( $constraint_name ) { + $name = $this->get_identifier_value( $constraint_name->get_first_child_node( 'identifier' ) ); + $key = strtolower( $name ); + if ( isset( $foreign_key_names[ $key ] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $foreign_key_names[ $key ] = true; + return $name; + } + + return $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ); + } + + /** + * Get the next implicit MySQL FOREIGN KEY constraint name. + * + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string Constraint name. + */ + private function get_next_implicit_foreign_key_constraint_name( string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + do { + $constraint_name = $this->get_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal ); + ++$foreign_key_ordinal; + $key = strtolower( $constraint_name ); + } while ( isset( $foreign_key_names[ $key ] ) ); + + $foreign_key_names[ $key ] = true; + return $constraint_name; + } + + /** + * Translate a MySQL data type. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param bool $is_auto_increment Whether AUTO_INCREMENT is present. + * @param string|null $mysql_column_type MySQL-facing column type. + * @return string PostgreSQL data type. + */ + private function translate_data_type( ?WP_Parser_Node $data_type, bool $is_auto_increment, ?string $mysql_column_type = null ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type = $this->get_normalized_mysql_data_type( $data_type ); + $integer_domain_type = $is_auto_increment ? null : $this->get_postgresql_mysql_integer_domain_type( $mysql_column_type ); + if ( null !== $integer_domain_type ) { + $postgresql_type = $integer_domain_type; + } elseif ( in_array( $type, array( 'bigint', 'int8' ), true ) ) { + $postgresql_type = 'bigint'; + } elseif ( 'serial' === $type ) { + $postgresql_type = 'bigint'; + } elseif ( in_array( $type, array( 'bit', 'bool', 'boolean', 'int', 'int1', 'int2', 'int3', 'int4', 'integer', 'mediumint', 'smallint', 'tinyint' ), true ) ) { + $postgresql_type = 'integer'; + } elseif ( in_array( $type, array( 'varchar', 'char' ), true ) ) { + $length = $this->get_field_length( $data_type ); + if ( 'char' === $type && null === $length ) { + $length = 1; + } + $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; + } elseif ( + in_array( $type, array( 'tinytext', 'mediumtext', 'longtext', 'json', 'datetime', 'timestamp', 'date', 'time', 'year' ), true ) + || $this->is_mysql_spatial_column_type( $type ) + ) { + $postgresql_type = $this->get_postgresql_mysql_text_domain_type( $type, $data_type ); + } elseif ( 'text' === $type ) { + $postgresql_type = 'text'; + } elseif ( 'enum' === $type ) { + $postgresql_type = $this->get_postgresql_mysql_enum_type( $mysql_column_type ); + } elseif ( 'set' === $type ) { + $postgresql_type = $this->get_postgresql_mysql_set_domain_type( $mysql_column_type ); + } elseif ( in_array( $type, array( 'binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob' ), true ) ) { + $postgresql_type = $this->get_postgresql_mysql_binary_domain_type( $type, $mysql_column_type ); + } elseif ( in_array( $type, array( 'float', 'double', 'real' ), true ) ) { + $numeric_domain_type = $this->get_postgresql_mysql_numeric_domain_type( $type, $mysql_column_type ); + if ( null !== $numeric_domain_type ) { + $postgresql_type = $numeric_domain_type; + } else { + $precision_fragment = $this->get_numeric_precision_fragment( $data_type ); + $postgresql_type = '' === $precision_fragment ? 'double precision' : 'numeric' . $precision_fragment; + } + } elseif ( in_array( $type, array( 'dec', 'decimal', 'fixed', 'numeric' ), true ) ) { + $numeric_domain_type = $this->get_postgresql_mysql_numeric_domain_type( $type, $mysql_column_type ); + $postgresql_type = null !== $numeric_domain_type ? $numeric_domain_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; + } + + /** + * Get a deterministic PostgreSQL enum type name for a MySQL ENUM definition. + * + * @param string|null $mysql_column_type MySQL-facing column type. + * @return string PostgreSQL enum type name. + */ + private function get_postgresql_mysql_enum_type( ?string $mysql_column_type ): string { + $mysql_column_type = trim( (string) $mysql_column_type ); + if ( ! preg_match( '/^enum\(.+\)$/i', $mysql_column_type ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL ENUM column type for PostgreSQL install DDL.' ); + } + + return '__wp_mysql_enum_' . substr( md5( $mysql_column_type ), 0, 16 ); + } + + /** + * Get a deterministic PostgreSQL domain type name for a MySQL SET definition. + * + * @param string|null $mysql_column_type MySQL-facing column type. + * @return string PostgreSQL domain type name. + */ + private function get_postgresql_mysql_set_domain_type( ?string $mysql_column_type ): string { + $mysql_column_type = trim( (string) $mysql_column_type ); + if ( ! preg_match( '/^set\(.+\)$/i', $mysql_column_type ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL SET column type for PostgreSQL install DDL.' ); + } + + return '__wp_mysql_set_' . substr( md5( $mysql_column_type ), 0, 16 ); + } + + /** + * Get a PostgreSQL integer-domain type that preserves a lossy MySQL integer type in catalogs. + * + * @param string|null $mysql_column_type MySQL-facing column type. + * @return string|null PostgreSQL domain type, or null when native PostgreSQL type metadata is enough. + */ + private function get_postgresql_mysql_integer_domain_type( ?string $mysql_column_type ): ?string { + $mysql_column_type = strtolower( trim( (string) $mysql_column_type ) ); + if ( ! preg_match( '/^(bit|bool|boolean|tinyint|smallint|mediumint|int|int1|int2|int3|int4|int8|integer|bigint)(?:\((\d+)\))?( unsigned)?$/', $mysql_column_type, $matches ) ) { + return null; + } + + $type = 'integer' === $matches[1] ? 'int' : $matches[1]; + $length = $matches[2] ?? ''; + $is_unsigned = isset( $matches[3] ) && '' !== $matches[3]; + if ( '' === $length && ! $is_unsigned && in_array( $type, array( 'int', 'bigint' ), true ) ) { + return null; + } + + $parts = array( '__wp_mysql', $type ); + if ( '' !== $length ) { + $parts[] = $length; + } + if ( $is_unsigned ) { + $parts[] = 'unsigned'; + } + + return implode( '_', $parts ); + } + + /** + * Get a PostgreSQL text-domain type that preserves a lossy MySQL type in catalogs. + * + * @param string $type Normalized MySQL type. + * @param WP_Parser_Node $data_type Data type node. + * @return string PostgreSQL domain type. + */ + private function get_postgresql_mysql_text_domain_type( string $type, WP_Parser_Node $data_type ): string { + if ( in_array( $type, array( 'datetime', 'timestamp', 'time' ), true ) ) { + $precision = $this->get_temporal_precision_fragment( $data_type ); + if ( '' !== $precision ) { + return sprintf( '__wp_mysql_%s_%d', $type, (int) trim( $precision, '()' ) ); + } + } + + return '__wp_mysql_' . $type; + } + + /** + * Get a PostgreSQL binary-domain type that preserves a lossy MySQL binary/blob type in catalogs. + * + * @param string $type Normalized MySQL type. + * @param string $mysql_column_type MySQL-facing column type. + * @return string PostgreSQL domain type. + */ + private function get_postgresql_mysql_binary_domain_type( string $type, string $mysql_column_type ): string { + if ( preg_match( '/^(binary|varbinary)\((\d+)\)$/', strtolower( trim( $mysql_column_type ) ), $matches ) ) { + return sprintf( '__wp_mysql_%s_%d', $matches[1], (int) $matches[2] ); + } + + return '__wp_mysql_' . $type; + } + + /** + * Get a PostgreSQL numeric-domain type that preserves lossy MySQL numeric type spelling in catalogs. + * + * @param string $type Normalized MySQL type. + * @param string|null $mysql_column_type MySQL-facing column type. + * @return string|null PostgreSQL domain type, or null when native PostgreSQL type metadata is enough. + */ + private function get_postgresql_mysql_numeric_domain_type( string $type, ?string $mysql_column_type ): ?string { + $mysql_column_type = strtolower( trim( (string) $mysql_column_type ) ); + if ( ! preg_match( '/^(dec|fixed|float|double|real|numeric)(?:\((\d+)(?:,(\d+))?\))?$/', $mysql_column_type, $matches ) ) { + return null; + } + + if ( 'decimal' === $type ) { + return null; + } + + if ( in_array( $type, array( 'double', 'numeric' ), true ) && ! isset( $matches[2] ) ) { + return null; + } + + $parts = array( '__wp_mysql', $matches[1] ); + if ( isset( $matches[2] ) && '' !== $matches[2] ) { + $parts[] = (string) (int) $matches[2]; + } + if ( isset( $matches[3] ) && '' !== $matches[3] ) { + $parts[] = (string) (int) $matches[3]; + } + + return implode( '_', $parts ); + } + + /** + * Translate a DEFAULT attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @param WP_Parser_Node|null $data_type Column data type node. + * @return string PostgreSQL DEFAULT clause. + */ + private function translate_default_attribute( WP_Parser_Node $attribute, ?WP_Parser_Node $data_type = null ): string { + $value_tokens = $this->get_default_attribute_value_tokens( $attribute ); + $expression_tokens = $this->strip_default_attribute_outer_parentheses( $value_tokens ); + + if ( + 1 === count( $expression_tokens ) + && $this->is_unquoted_mysql_null_token( $expression_tokens[0] ) + ) { + return 'DEFAULT NULL'; + } + + $current_timestamp_default = $this->get_current_timestamp_default_data( $attribute ); + if ( null !== $current_timestamp_default ) { + return 'DEFAULT ' . $this->get_postgresql_mysql_current_timestamp_sql( $current_timestamp_default['fsp'] ); + } + + if ( $this->is_generated_default_attribute( $attribute ) ) { + $expression = $this->translate_generated_default_expression( + $expression_tokens, + $data_type + ); + if ( null === $expression ) { + throw new InvalidArgumentException( 'Unsupported column DEFAULT expression.' ); + } + + return 'DEFAULT (' . $expression . ')'; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + if ( $this->is_unquoted_mysql_null_token( $token ) ) { + return 'DEFAULT NULL'; + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return 'DEFAULT ' . $this->quote_string_literal( $token->get_value() ); + } + } + + throw new InvalidArgumentException( 'Unsupported column DEFAULT expression.' ); + } + + /** + * Translate a MySQL generated DEFAULT expression to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param WP_Parser_Node|null $data_type Column data type node. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function translate_generated_default_expression( array $tokens, ?WP_Parser_Node $data_type = null ): ?string { + $expression = $this->translate_generated_default_expression_tokens( $tokens, 0, count( $tokens ) ); + if ( null === $expression ) { + return null; + } + + if ( empty( $expression['temporal'] ) ) { + return $expression['sql']; + } + + $base_type = $data_type ? $this->get_base_mysql_column_type( $this->get_node_value( $data_type ) ) : ''; + if ( in_array( $base_type, array( 'datetime', 'timestamp' ), true ) ) { + return $this->get_postgresql_mysql_temporal_expression_sql( $expression['sql'], 'YYYY-MM-DD HH24:MI:SS', $expression['fsp'] ); + } + + if ( 'date' === $base_type ) { + return sprintf( "TO_CHAR(%s, 'YYYY-MM-DD')", $expression['sql'] ); + } + + if ( 'time' === $base_type ) { + return sprintf( "TO_CHAR(%s, 'HH24:MI:SS')", $expression['sql'] ); + } + + return $expression['sql']; + } + + /** + * Translate supported generated DEFAULT expression tokens. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int}|null PostgreSQL SQL and type hint, or null when unsupported. + */ + private function translate_generated_default_expression_tokens( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $sql = ''; + $temporal = false; + $fsp = 0; + + for ( $position = $start; $position < $end; ++$position ) { + $function = $this->translate_generated_default_function_call( $tokens, $position, $end ); + if ( null !== $function ) { + $sql = $this->append_generated_default_sql_fragment( $sql, $function['sql'] ); + $temporal = $temporal || $function['temporal']; + $fsp = max( $fsp, $function['fsp'] ); + $position = $function['next'] - 1; + continue; + } + + $token = $tokens[ $position ]; + if ( $this->is_generated_default_literal_token( $token ) ) { + $sql = $this->append_generated_default_sql_fragment( + $sql, + $this->translate_generated_default_literal_token( $token ) + ); + continue; + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || $this->is_unquoted_mysql_null_token( $token ) ) { + $sql = $this->append_generated_default_sql_fragment( $sql, 'NULL' ); + continue; + } + + if ( in_array( $token->id, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR, WP_MySQL_Lexer::MULT_OPERATOR, WP_MySQL_Lexer::DIV_OPERATOR, WP_MySQL_Lexer::MOD_OPERATOR ), true ) ) { + $sql = rtrim( $sql ) . ' ' . $token->get_bytes() . ' '; + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $sql = $this->append_generated_default_sql_fragment( $sql, '(' ); + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + $sql = rtrim( $sql ) . ')'; + continue; + } + + return null; + } + + $sql = trim( $sql ); + if ( '' === $sql || ! $this->generated_default_parentheses_are_balanced( $sql ) ) { + return null; + } + + return array( + 'sql' => $sql, + 'temporal' => $temporal, + 'fsp' => $fsp, + ); + } + + /** + * Translate a supported generated DEFAULT function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_function_call( array $tokens, int $position, int $end ): ?array { + $token = $tokens[ $position ] ?? null; + if ( ! $token ) { + return null; + } + + if ( $this->is_generated_default_current_timestamp_function_token( $token ) ) { + $next = $position + 1; + $fsp = 0; + if ( isset( $tokens[ $next ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $next ]->id ) { + $close = $this->find_matching_generated_default_parenthesis( $tokens, $next, $end ); + if ( $next + 1 !== $close ) { + if ( $next + 2 !== $close ) { + return null; + } + + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[ $next + 1 ] ?? null ); + if ( null === $fsp ) { + return null; + } + } + + $next = $close + 1; + } + + return array( + 'sql' => $this->get_postgresql_current_timestamp_expression_sql( $fsp ), + 'temporal' => true, + 'fsp' => $fsp, + 'next' => $next, + ); + } + + $function_name = strtoupper( $token->get_value() ); + if ( 'CONCAT' === $function_name ) { + return $this->translate_generated_default_concat_function( $tokens, $position, $end ); + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ) + ) { + return $this->translate_generated_default_date_arithmetic_function( $tokens, $position, $end ); + } + + return null; + } + + /** + * Translate a generated DEFAULT CONCAT(...) function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_concat_function( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close = $this->find_matching_generated_default_parenthesis( $tokens, $position + 1, $end ); + if ( null === $close ) { + return null; + } + + $arguments = $this->split_generated_default_arguments( $tokens, $position + 2, $close ); + if ( null === $arguments ) { + return null; + } + + if ( empty( $arguments ) ) { + return array( + 'sql' => $this->quote_string_literal( '' ), + 'temporal' => false, + 'fsp' => 0, + 'next' => $close + 1, + ); + } + + $sql_arguments = array(); + foreach ( $arguments as $argument ) { + $expression = $this->translate_generated_default_expression_tokens( $tokens, $argument[0], $argument[1] ); + if ( null === $expression ) { + return null; + } + + $sql_arguments[] = 'CAST(' . $expression['sql'] . ' AS text)'; + } + + return array( + 'sql' => '(' . implode( ' || ', $sql_arguments ) . ')', + 'temporal' => false, + 'fsp' => 0, + 'next' => $close + 1, + ); + } + + /** + * Translate a generated DEFAULT DATE_ADD/DATE_SUB function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_date_arithmetic_function( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close = $this->find_matching_generated_default_parenthesis( $tokens, $position + 1, $end ); + if ( null === $close ) { + return null; + } + + $arguments = $this->split_generated_default_arguments( $tokens, $position + 2, $close ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + + $base = $this->translate_generated_default_expression_tokens( $tokens, $arguments[0][0], $arguments[0][1] ); + if ( null === $base || empty( $base['temporal'] ) ) { + return null; + } + + $interval = $this->translate_generated_default_interval_argument( $tokens, $arguments[1][0], $arguments[1][1] ); + if ( null === $interval ) { + return null; + } + + $operator = in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::DATE_SUB_SYMBOL, WP_MySQL_Lexer::SUBDATE_SYMBOL ), true ) ? '-' : '+'; + + return array( + 'sql' => '(' . $base['sql'] . ' ' . $operator . ' (' . $interval . '))', + 'temporal' => true, + 'fsp' => $base['fsp'], + 'next' => $close + 1, + ); + } + + /** + * Translate a simple INTERVAL argument for generated DEFAULT DATE_ADD/DATE_SUB. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return string|null PostgreSQL interval SQL, or null when unsupported. + */ + private function translate_generated_default_interval_argument( array $tokens, int $start, int $end ): ?string { + if ( $start + 2 > $end || WP_MySQL_Lexer::INTERVAL_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $unit = $this->get_generated_default_interval_unit( $tokens[ $end - 1 ] ); + if ( null === $unit ) { + return null; + } + + $value = $this->translate_generated_default_expression_tokens( $tokens, $start + 1, $end - 1 ); + if ( null === $value || ! empty( $value['temporal'] ) ) { + return null; + } + + return $value['sql'] . ' * INTERVAL ' . $this->quote_string_literal( $unit ); + } + + /** + * Get PostgreSQL interval unit text for a supported MySQL interval unit token. + * + * @param WP_MySQL_Token $token MySQL interval unit token. + * @return string|null PostgreSQL interval unit, or null when unsupported. + */ + private function get_generated_default_interval_unit( WP_MySQL_Token $token ): ?string { + $units = array( + WP_MySQL_Lexer::MICROSECOND_SYMBOL => '1 microsecond', + WP_MySQL_Lexer::SECOND_SYMBOL => '1 second', + WP_MySQL_Lexer::MINUTE_SYMBOL => '1 minute', + WP_MySQL_Lexer::HOUR_SYMBOL => '1 hour', + WP_MySQL_Lexer::DAY_SYMBOL => '1 day', + WP_MySQL_Lexer::WEEK_SYMBOL => '1 week', + WP_MySQL_Lexer::MONTH_SYMBOL => '1 month', + WP_MySQL_Lexer::QUARTER_SYMBOL => '3 months', + WP_MySQL_Lexer::YEAR_SYMBOL => '1 year', + ); + + return $units[ $token->id ] ?? null; + } + + /** + * Split top-level function arguments within a generated DEFAULT expression. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return array|null Argument offset ranges, or null for malformed input. + */ + private function split_generated_default_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start === $end ) { + return array(); + } + + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $argument_start === $position ) { + return null; + } + + $arguments[] = array( $argument_start, $position ); + $argument_start = $position + 1; + } + } + + if ( 0 !== $depth || $argument_start === $end ) { + return null; + } + + $arguments[] = array( $argument_start, $end ); + + return $arguments; + } + + /** + * Find the closing parenthesis for a generated DEFAULT expression range. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $open_position Opening parenthesis offset. + * @param int $end End offset, exclusive. + * @return int|null Closing parenthesis offset, or null. + */ + private function find_matching_generated_default_parenthesis( array $tokens, int $open_position, int $end ): ?int { + if ( ! isset( $tokens[ $open_position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $open_position ]->id ) { + return null; + } + + $depth = 0; + for ( $position = $open_position; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( 0 === $depth ) { + return $position; + } + } + } + + return null; + } + + /** + * Append an expression fragment with minimal spacing. + * + * @param string $sql Existing SQL. + * @param string $fragment Fragment to append. + * @return string Combined SQL. + */ + private function append_generated_default_sql_fragment( string $sql, string $fragment ): string { + if ( '' === $sql || '(' === $fragment || '(' === substr( rtrim( $sql ), -1 ) || ' ' === substr( $sql, -1 ) ) { + return $sql . $fragment; + } + + if ( ')' === $fragment || ',' === $fragment ) { + return rtrim( $sql ) . $fragment; + } + + return $sql . ' ' . $fragment; + } + + /** + * Check whether serialized generated DEFAULT SQL has balanced parentheses. + * + * @param string $sql PostgreSQL expression SQL. + * @return bool Whether parentheses are balanced. + */ + private function generated_default_parentheses_are_balanced( string $sql ): bool { + $depth = 0; + $quote = null; + $length = strlen( $sql ); + + for ( $i = 0; $i < $length; ++$i ) { + $character = $sql[ $i ]; + if ( "'" === $quote ) { + if ( "'" === $character ) { + if ( isset( $sql[ $i + 1 ] ) && "'" === $sql[ $i + 1 ] ) { + ++$i; + continue; + } + + $quote = null; + } + continue; + } + + if ( "'" === $character ) { + $quote = "'"; + continue; + } + + if ( '(' === $character ) { + ++$depth; + } elseif ( ')' === $character ) { + --$depth; + if ( $depth < 0 ) { + return false; + } + } + } + + return 0 === $depth && null === $quote; + } + + /** + * Check whether a token is a supported generated DEFAULT literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is supported. + */ + private function is_generated_default_literal_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::FLOAT_NUMBER, + ), + true + ); + } + + /** + * Translate a supported generated DEFAULT literal token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string PostgreSQL literal SQL. + */ + private function translate_generated_default_literal_token( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->quote_string_literal( $token->get_value() ); + } + + return $token->get_value(); + } + + /** + * Check whether a token is CURRENT_TIMESTAMP or NOW for generated DEFAULTs. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a supported timestamp function token. + */ + private function is_generated_default_current_timestamp_function_token( WP_MySQL_Token $token ): bool { + return $this->is_current_timestamp_token( $token ) + || WP_MySQL_Lexer::NOW_SYMBOL === $token->id; + } + + /** + * Translate a non-primary MySQL key to a PostgreSQL CREATE INDEX statement. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name. + * @param bool $if_not_exists Whether CREATE TABLE used IF NOT EXISTS. + * @param array $column_types Column types keyed by lowercase name. + * @return string|null PostgreSQL CREATE INDEX statement, or null for metadata-only MySQL index types. + */ + private function translate_secondary_index( WP_Parser_Node $table_constraint, string $table_name, bool $if_not_exists, array $column_types ): ?string { + if ( + $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) + || $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) + ) { + return null; + } + + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name = $index_name_node ? $this->get_identifier_value( $index_name_node->get_first_child_node( 'indexName' ) ) : null; + if ( null === $index_name || '' === $index_name ) { + $key_parts = $this->get_key_parts( $table_constraint ); + $index_name = $key_parts[0]; + } + + return sprintf( + 'CREATE %sINDEX %s%s ON %s (%s)', + $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? 'UNIQUE ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name . '__' . $index_name ), + $this->quote_identifier( $table_name ), + implode( ', ', $this->quote_key_parts( $table_constraint, true, true, $column_types ) ) + ); + } + + /** + * Validate MySQL index options that PostgreSQL DDL can safely ignore. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + */ + private function validate_mysql_table_constraint_index_options( WP_Parser_Node $table_constraint ): void { + $is_metadata_only = $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) + || $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ); + + if ( + $is_metadata_only + && ( + ! empty( $table_constraint->get_child_nodes( 'indexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'fulltextIndexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'spatialIndexOption' ) ) + ) + ) { + if ( ! $this->allow_metadata_only_index_options || ! $this->has_only_supported_metadata_index_options( $table_constraint ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + + if ( + ! $is_metadata_only + && ( + ! empty( $table_constraint->get_child_nodes( 'fulltextIndexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'spatialIndexOption' ) ) + ) + ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + + foreach ( $table_constraint->get_descendant_nodes( 'indexType' ) as $index_type ) { + if ( ! $this->is_supported_mysql_btree_index_type( $index_type ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + + $is_primary = $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); + foreach ( $table_constraint->get_child_nodes( 'indexOption' ) as $index_option ) { + if ( ! $this->is_supported_mysql_table_index_option( $index_option, $is_primary ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + } + + /** + * Check whether metadata-only index options can be preserved or ignored safely. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return bool Whether all metadata-only index options are supported. + */ + private function has_only_supported_metadata_index_options( WP_Parser_Node $table_constraint ): bool { + foreach ( array( 'indexOption', 'fulltextIndexOption', 'spatialIndexOption' ) as $rule_name ) { + foreach ( $table_constraint->get_child_nodes( $rule_name ) as $option ) { + if ( + 'fulltextIndexOption' === $rule_name + && $option->has_child_token( WP_MySQL_Lexer::WITH_SYMBOL ) + && $option->has_child_token( WP_MySQL_Lexer::PARSER_SYMBOL ) + && $option->get_first_child_node( 'identifier' ) + ) { + continue; + } + + $common_option = $option->get_first_child_node( 'commonIndexOption' ); + if ( ! $common_option ) { + return false; + } + + if ( + ! $common_option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) + && ! $common_option->has_child_token( WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL ) + && ! $common_option->has_child_node( 'visibility' ) + ) { + return false; + } + } + } + + return true; + } + + /** + * Check whether a table index option can be ignored by PostgreSQL DDL. + * + * @param WP_Parser_Node $index_option Index option node. + * @param bool $is_primary Whether this is a PRIMARY KEY constraint. + * @return bool Whether the option is supported. + */ + private function is_supported_mysql_table_index_option( WP_Parser_Node $index_option, bool $is_primary ): bool { + $index_type_clause = $index_option->get_first_child_node( 'indexTypeClause' ); + if ( $index_type_clause ) { + $index_type = $index_type_clause->get_first_child_node( 'indexType' ); + return $index_type && $this->is_supported_mysql_btree_index_type( $index_type ); + } + + $common_option = $index_option->get_first_child_node( 'commonIndexOption' ); + if ( ! $common_option ) { + return false; + } + + if ( + $common_option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) + || $common_option->has_child_token( WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL ) + ) { + return true; + } + + $visibility = $common_option->get_first_child_node( 'visibility' ); + if ( ! $visibility ) { + return false; + } + + return ! $is_primary || $visibility->has_child_token( WP_MySQL_Lexer::VISIBLE_SYMBOL ); + } + + /** + * Check whether a MySQL index type maps to a PostgreSQL btree index. + * + * InnoDB reports HASH declarations as BTREE, so HASH is accepted for + * MySQL-compatible metadata normalization. + * + * @param WP_Parser_Node $index_type Index type node. + * @return bool Whether the index type is supported as a btree index. + */ + private function is_supported_mysql_btree_index_type( WP_Parser_Node $index_type ): bool { + return $index_type->has_child_token( WP_MySQL_Lexer::BTREE_SYMBOL ) + || $index_type->has_child_token( WP_MySQL_Lexer::HASH_SYMBOL ); + } + + /** + * Get quoted key parts from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param bool $use_prefix_expressions Whether explicit key-part prefix lengths should become expressions. + * @param bool $include_direction Whether ASC/DESC key-part direction should be included. + * @param array $column_types Column types keyed by lowercase name. + * @return string[] Quoted PostgreSQL column names. + */ + private function quote_key_parts( WP_Parser_Node $table_constraint, bool $use_prefix_expressions = false, bool $include_direction = false, array $column_types = array() ): array { + $quoted_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $sub_part = $this->get_field_length( $key_part ); + if ( $use_prefix_expressions && null === $sub_part ) { + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + } + + if ( $use_prefix_expressions && null !== $sub_part ) { + $quoted_part = $this->get_prefix_key_part_expression_sql( $column_name, $sub_part ); + } else { + $quoted_part = $this->quote_identifier( $column_name ); + } + + if ( $include_direction ) { + $quoted_part .= $this->get_key_part_direction_sql( $key_part ); + } + + $quoted_parts[] = $quoted_part; + } + + if ( empty( $quoted_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $quoted_parts; + } + + /** + * Get PostgreSQL ASC/DESC SQL for a MySQL key part. + * + * @param WP_Parser_Node $key_part Key part node. + * @return string Direction SQL, including leading space, or empty string. + */ + private function get_key_part_direction_sql( WP_Parser_Node $key_part ): string { + $direction = $key_part->get_first_child_node( 'direction' ); + if ( ! $direction ) { + return ''; + } + + $value = strtoupper( $this->get_node_value( $direction ) ); + if ( 'ASC' === $value || 'DESC' === $value ) { + return ' ' . $value; + } + + return ''; + } + + /** + * Get PostgreSQL SQL for a MySQL prefix key part. + * + * @param string $column_name Column name. + * @param int $sub_part Prefix length. + * @return string PostgreSQL expression SQL. + */ + private function get_prefix_key_part_expression_sql( string $column_name, int $sub_part ): string { + return sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $this->quote_identifier( $column_name ), + $sub_part + ); + } + + /** + * Get key part column names. + * + * Prefix lengths are handled by quote_key_parts() when a PostgreSQL index + * expression is needed; this helper returns only the underlying names. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Column names. + */ + private function get_key_parts( WP_Parser_Node $table_constraint ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $key_parts[] = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get a CREATE TABLE table name. + * + * @param WP_Parser_Node $create_table Create table node. + * @return string Table name. + */ + private function get_table_name( WP_Parser_Node $create_table ): string { + return $this->get_last_identifier_value( $create_table->get_first_child_node( 'tableName' ) ); + } + + /** + * Get the last identifier value in a node. + * + * @param WP_Parser_Node|null $node Node containing an identifier. + * @return string Identifier value. + */ + private function get_last_identifier_value( ?WP_Parser_Node $node ): string { + if ( ! $node ) { + throw new InvalidArgumentException( 'Expected identifier node.' ); + } + + $identifier = null; + $tokens = $node->get_descendant_tokens(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $identifier = $token->get_value(); + } + } + + if ( null !== $identifier ) { + return $identifier; + } + + if ( 1 === count( $tokens ) && '' !== $tokens[0]->get_value() ) { + return $tokens[0]->get_value(); + } + + throw new InvalidArgumentException( 'Expected identifier token.' ); + } + + /** + * Get the first identifier value in a node. + * + * @param WP_Parser_Node|null $node Node containing an identifier. + * @return string Identifier value. + */ + private function get_identifier_value( ?WP_Parser_Node $node ): string { + if ( ! $node ) { + throw new InvalidArgumentException( 'Expected identifier node.' ); + } + + $tokens = $node->get_descendant_tokens(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $token->get_value(); + } + } + + if ( 1 === count( $tokens ) && '' !== $tokens[0]->get_value() ) { + return $tokens[0]->get_value(); + } + + throw new InvalidArgumentException( 'Expected identifier token.' ); + } + + /** + * Extract metadata for a CREATE TABLE statement. + * + * @param WP_Parser_Node $create_table Create table node. + * @return array Table metadata. + */ + private function extract_create_table_metadata( WP_Parser_Node $create_table, bool $include_indexes = false ): array { + $table_name = $this->get_table_name( $create_table ); + $charset = $this->get_table_charset_and_collation( $create_table ); + $table_comment = $this->get_table_comment( $create_table ); + $columns = array(); + $column_types = array(); + $indexes = array(); + $foreign_keys = array(); + $checks = array(); + $ordinal = 1; + $index_ordinal = 1; + $foreign_key_ordinal = 1; + $foreign_key_names = array(); + $check_ordinal = 1; + + list ( $table_charset, $table_collation ) = $charset; + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + return array( + 'table_name' => $table_name, + 'charset' => $table_charset, + 'collation' => $table_collation, + 'comment' => $table_comment, + 'columns' => array(), + ); + } + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + $data_type = $field_definition ? $field_definition->get_first_child_node( 'dataType' ) : null; + $column_type = $this->get_mysql_column_type( $data_type, $field_definition ); + $is_serial = $this->is_serial_data_type( $data_type ); + $is_inline_primary = $field_definition && $this->has_inline_primary_key( $field_definition ); + + list ( $charset, $collation ) = $this->get_column_charset_and_collation( + $field_definition, + $this->get_base_mysql_column_type( $column_type ), + $table_charset, + $table_collation + ); + + $column_metadata = array( + 'name' => $name, + 'type' => $column_type, + 'charset' => $charset, + 'collation' => $collation, + 'comment' => $field_definition ? $this->get_column_comment( $field_definition ) : '', + 'ordinal' => $ordinal, + ); + + if ( $include_indexes ) { + $column_metadata['nullable'] = $is_serial || $is_inline_primary || ( $field_definition && $this->has_not_null_column_attribute( $field_definition ) ) ? 'NO' : 'YES'; + $column_metadata['default'] = $field_definition ? $this->get_column_default_metadata( $field_definition ) : null; + $column_metadata['extra'] = $field_definition ? $this->get_column_extra_metadata( $field_definition, $is_serial ) : ''; + } + + $columns[] = $column_metadata; + $column_types[ strtolower( $name ) ] = $column_type; + + if ( $include_indexes && $field_definition ) { + foreach ( $this->extract_inline_index_metadata( $name, $field_definition, $column_type, $index_ordinal ) as $index ) { + $indexes[] = $index; + ++$index_ordinal; + } + + $foreign_key = $this->extract_inline_foreign_key_metadata( $table_name, $name, $field_definition, $column_definition, $foreign_key_ordinal, $foreign_key_names ); + if ( null !== $foreign_key ) { + $foreign_keys[] = $foreign_key; + } + + $column_attributes = $field_definition->get_child_nodes( 'columnAttribute' ); + foreach ( $column_attributes as $attribute_index => $attribute ) { + if ( ! $attribute->get_first_child_node( 'checkConstraint' ) ) { + continue; + } + + $checks[] = $this->extract_check_constraint_metadata( + $attribute, + $table_name, + $check_ordinal, + $this->is_followed_by_not_enforced_column_attribute( $column_attributes, $attribute_index ) + ); + } + + $inline_check = $this->get_inline_check_constraint_node( $column_definition ); + if ( $inline_check ) { + $checks[] = $this->extract_check_constraint_metadata( $inline_check, $table_name, $check_ordinal ); + } + } + + ++$ordinal; + continue; + } + + if ( $include_indexes ) { + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( $table_constraint ) { + if ( $table_constraint->get_first_child_node( 'checkConstraint' ) ) { + $checks[] = $this->extract_check_constraint_metadata( $table_constraint, $table_name, $check_ordinal ); + continue; + } + + if ( $this->is_table_foreign_key_constraint( $table_constraint ) ) { + $foreign_keys[] = $this->extract_table_foreign_key_metadata( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + continue; + } + + $this->validate_mysql_table_constraint_index_options( $table_constraint ); + $indexes[] = $this->extract_index_metadata( $table_constraint, $index_ordinal, $column_types ); + ++$index_ordinal; + } + } + } + + $metadata = array( + 'table_name' => $table_name, + 'charset' => $table_charset, + 'collation' => $table_collation, + 'comment' => $table_comment, + 'columns' => $columns, + ); + + if ( $include_indexes ) { + $metadata['indexes'] = $indexes; + $metadata['foreign_keys'] = $foreign_keys; + $metadata['checks'] = $checks; + } + + return $metadata; + } + + /** + * Extract metadata for a CHECK constraint. + * + * @param WP_Parser_Node $node Node containing a checkConstraint child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @param bool $not_enforced Whether enforcement was represented by a following sibling node. + * @return array CHECK constraint metadata. + */ + private function extract_check_constraint_metadata( WP_Parser_Node $node, string $table_name, int &$check_ordinal, bool $not_enforced = false ): array { + $check_constraint = 'checkConstraint' === $node->rule_name ? $node : $node->get_first_child_node( 'checkConstraint' ); + if ( ! $check_constraint ) { + throw new InvalidArgumentException( 'Expected CHECK constraint node.' ); + } + + $check_clause = $this->render_check_constraint_expression( $check_constraint, false ); + $postgresql_check_clause = $this->render_check_constraint_expression( $check_constraint, true ); + $metadata = array( + 'name' => $this->get_check_constraint_name( $node, $table_name, $check_ordinal ), + 'check_clause' => $check_clause, + 'enforced' => ( $not_enforced || $this->is_check_constraint_not_enforced( $node ) ) ? 'NO' : 'YES', + ); + + if ( trim( $check_clause ) !== trim( $postgresql_check_clause ) ) { + $metadata['postgresql_check_clause'] = $postgresql_check_clause; + } + + return $metadata; + } + + /** + * Check whether a field definition has an inline PRIMARY KEY attribute. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether inline PRIMARY KEY is present. + */ + private function has_inline_primary_key( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a field definition has an inline UNIQUE attribute. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether inline UNIQUE is present. + */ + private function has_inline_unique_key( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ) { + return true; + } + } + + return false; + } + + /** + * Extract metadata for inline PRIMARY KEY and UNIQUE attributes. + * + * @param string $column_name Column name. + * @param WP_Parser_Node $field_definition Field definition node. + * @param string $column_type MySQL column type. + * @param int $index_ordinal Current index ordinal. + * @return array[] Index metadata rows. + */ + private function extract_inline_index_metadata( string $column_name, WP_Parser_Node $field_definition, string $column_type, int $index_ordinal ): array { + $indexes = array(); + $column_types = array( strtolower( $column_name ) => $column_type ); + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + $column = array( + 'column_name' => $column_name, + 'seq_in_index' => 1, + 'sub_part' => $sub_part, + ); + + if ( $this->has_inline_primary_key( $field_definition ) ) { + $indexes[] = array( + 'name' => 'PRIMARY', + 'ordinal' => $index_ordinal, + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'comment' => '', + 'columns' => array( $column ), + ); + ++$index_ordinal; + } + + if ( + ! $this->has_inline_primary_key( $field_definition ) + && ( + $this->has_inline_unique_key( $field_definition ) + || $this->is_serial_data_type( $field_definition->get_first_child_node( 'dataType' ) ) + ) + ) { + $indexes[] = array( + 'name' => $column_name, + 'ordinal' => $index_ordinal, + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'comment' => '', + 'columns' => array( $column ), + ); + } + + return $indexes; + } + + /** + * Extract metadata for an inline foreign key reference. + * + * @param string $table_name Table name. + * @param string $column_name Local column name. + * @param WP_Parser_Node $field_definition Field definition node. + * @param WP_Parser_Node $column_definition Column definition node. + * @param int $foreign_key_ordinal Current foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return array|null Foreign key metadata, or null. + */ + private function extract_inline_foreign_key_metadata( string $table_name, string $column_name, WP_Parser_Node $field_definition, WP_Parser_Node $column_definition, int &$foreign_key_ordinal, array &$foreign_key_names ): ?array { + $references = $this->get_inline_references_node( $column_definition ); + if ( ! $references ) { + return null; + } + + $reference = $this->extract_inline_reference_metadata( $references ); + if ( 1 !== count( $reference['referenced_columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + return array( + 'name' => $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ), + 'columns' => array( $column_name ), + 'referenced_schema' => $reference['referenced_schema'], + 'referenced_table' => $reference['referenced_table'], + 'referenced_columns' => $reference['referenced_columns'], + 'update_rule' => $reference['update_rule'], + 'delete_rule' => $reference['delete_rule'], + ); + } + + /** + * Extract metadata for a table-level foreign key reference. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name. + * @param int $foreign_key_ordinal Current foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return array Foreign key metadata. + */ + private function extract_table_foreign_key_metadata( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): array { + $references = $table_constraint->get_first_child_node( 'references' ); + if ( ! $references ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $columns = $this->get_foreign_key_columns( $table_constraint ); + $reference = $this->extract_inline_reference_metadata( $references ); + if ( count( $columns ) !== count( $reference['referenced_columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + return array( + 'name' => $this->get_foreign_key_constraint_name( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ), + 'columns' => $columns, + 'referenced_schema' => $reference['referenced_schema'], + 'referenced_table' => $reference['referenced_table'], + 'referenced_columns' => $reference['referenced_columns'], + 'update_rule' => $reference['update_rule'], + 'delete_rule' => $reference['delete_rule'], + ); + } + + /** + * Get local column names from a table-level FOREIGN KEY constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Local column names. + */ + private function get_foreign_key_columns( WP_Parser_Node $table_constraint ): array { + $columns = array(); + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + if ( null !== $this->get_field_length( $key_part ) || $key_part->get_first_child_node( 'direction' ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $columns[] = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + } + + if ( empty( $columns ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + return $columns; + } + + /** + * Extract metadata for a table index. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param int $index_ordinal Index ordinal. + * @param array $column_types Column types keyed by lowercase name. + * @return array Index metadata. + */ + private function extract_index_metadata( WP_Parser_Node $table_constraint, int $index_ordinal, array $column_types ): array { + $is_primary = $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); + $is_spatial_index = $this->is_mysql_spatial_index_metadata( $table_constraint, $column_types ); + $key_parts = $this->get_key_part_metadata( $table_constraint, $column_types, $is_spatial_index ); + $key_name = $is_primary ? 'PRIMARY' : $this->get_index_name( $table_constraint, $key_parts ); + + return array( + 'name' => $key_name, + 'ordinal' => $index_ordinal, + 'non_unique' => $is_primary || $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? '0' : '1', + 'index_type' => $this->get_mysql_index_type_metadata( $table_constraint, $is_spatial_index ), + 'comment' => $this->get_index_comment( $table_constraint ), + 'columns' => $key_parts, + ); + } + + /** + * Get table comment metadata from a CREATE TABLE node. + * + * @param WP_Parser_Node $create_table CREATE TABLE node. + * @return string Table comment. + */ + private function get_table_comment( WP_Parser_Node $create_table ): string { + foreach ( $create_table->get_descendant_nodes( 'createTableOption' ) as $option ) { + if ( ! $option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $option->get_first_child_node( 'textStringLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get column comment metadata from a field definition node. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return string Column comment. + */ + private function get_column_comment( WP_Parser_Node $field_definition ): string { + foreach ( $field_definition->get_descendant_nodes( 'columnAttribute' ) as $attribute ) { + if ( ! $attribute->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $attribute->get_first_child_node( 'textLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get index comment metadata from a table constraint node. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string Index comment. + */ + private function get_index_comment( WP_Parser_Node $table_constraint ): string { + foreach ( $table_constraint->get_descendant_nodes( 'commonIndexOption' ) as $option ) { + if ( ! $option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $option->get_first_child_node( 'textLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get an index name from a table constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $key_parts Key part metadata. + * @return string Index name. + */ + private function get_index_name( WP_Parser_Node $table_constraint, array $key_parts ): string { + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name_node = $index_name_node ? $index_name_node->get_first_child_node( 'indexName' ) : $table_constraint->get_first_child_node( 'indexName' ); + + if ( $index_name_node ) { + return $this->get_identifier_value( $index_name_node ); + } + + return (string) $key_parts[0]['column_name']; + } + + /** + * Get MySQL SHOW INDEX Index_type metadata. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param bool $is_spatial_index Whether the index targets spatial data. + * @return string Index type. + */ + private function get_mysql_index_type_metadata( WP_Parser_Node $table_constraint, bool $is_spatial_index ): string { + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + return 'FULLTEXT'; + } + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) || $is_spatial_index ) { + return 'SPATIAL'; + } + + return 'BTREE'; + } + + /** + * Check whether index metadata should use MySQL SPATIAL semantics. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $column_types Column types keyed by lowercase name. + * @return bool Whether the index is spatial. + */ + private function is_mysql_spatial_index_metadata( WP_Parser_Node $table_constraint, array $column_types ): bool { + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) ) { + return true; + } + + $key_part = $table_constraint->get_first_descendant_node( 'keyPart' ); + if ( ! $key_part ) { + return false; + } + + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $column_type = $column_types[ strtolower( $column_name ) ] ?? null; + + return is_string( $column_type ) && $this->is_mysql_spatial_column_type( $column_type ); + } + + /** + * Get key part metadata from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $column_types Column types keyed by lowercase name. + * @param bool $is_spatial_index Whether the index targets spatial data. + * @return array[] Key part metadata. + */ + private function get_key_part_metadata( WP_Parser_Node $table_constraint, array $column_types, bool $is_spatial_index ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $sub_part = $this->get_field_length( $key_part ); + if ( null === $sub_part && $is_spatial_index ) { + $sub_part = 32; + } elseif ( null === $sub_part && ! $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + } + + $key_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $key_parts ) + 1, + 'collation' => $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ? null : $this->get_key_part_collation_metadata( $key_part ), + 'sub_part' => $sub_part, + ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get MySQL SHOW INDEX Collation metadata for a key part. + * + * @param WP_Parser_Node $key_part Key part node. + * @return string MySQL collation metadata. + */ + private function get_key_part_collation_metadata( WP_Parser_Node $key_part ): string { + $direction = $key_part->get_first_child_node( 'direction' ); + if ( ! $direction ) { + return 'A'; + } + + return 'DESC' === strtoupper( $this->get_node_value( $direction ) ) ? 'D' : 'A'; + } + + /** + * Get the implicit MySQL prefix length for oversized utf8mb4 string indexes. + * + * @param string $column_name Column name. + * @param array $column_types Column types keyed by lowercase name. + * @return int|null Sub part length. + */ + private function get_implicit_index_sub_part( string $column_name, array $column_types ): ?int { + $column_type = $column_types[ strtolower( $column_name ) ] ?? null; + if ( ! is_string( $column_type ) || ! preg_match( '/^(?:var)?char\((\d+)\)$/i', $column_type, $matches ) ) { + return null; + } + + $length = (int) $matches[1]; + return $length > 191 ? 191 : null; + } + + /** + * Get a MySQL default value for DESCRIBE metadata. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return string|null Default value. + */ + private function get_column_default_metadata( WP_Parser_Node $field_definition ): ?string { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( ! $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + continue; + } + + $value_tokens = $this->get_default_attribute_value_tokens( $attribute ); + $expression_tokens = $this->strip_default_attribute_outer_parentheses( $value_tokens ); + + if ( + 1 === count( $expression_tokens ) + && $this->is_unquoted_mysql_null_token( $expression_tokens[0] ) + ) { + return null; + } + + $current_timestamp_default = $this->get_current_timestamp_default_metadata( $attribute ); + if ( null !== $current_timestamp_default ) { + return $current_timestamp_default; + } + + if ( $this->is_generated_default_attribute( $attribute ) ) { + return $this->get_generated_default_metadata_expression( + $expression_tokens + ); + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + if ( $this->is_unquoted_mysql_null_token( $token ) ) { + return null; + } + + return $token->get_value(); + } + } + + return null; + } + + /** + * Get MySQL-facing metadata SQL for a generated DEFAULT expression. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @return string MySQL-facing expression SQL. + */ + private function get_generated_default_metadata_expression( array $tokens ): string { + $sql = ''; + $previous_token = null; + + foreach ( $tokens as $token ) { + if ( '' !== $sql && $this->generated_default_metadata_tokens_need_space( $previous_token, $token ) ) { + $sql .= ' '; + } + + $sql .= $token->get_bytes(); + $previous_token = $token; + } + + return $sql; + } + + /** + * Decide whether two generated DEFAULT metadata tokens need a separating space. + * + * @param WP_MySQL_Token|null $previous Previous token, or null. + * @param WP_MySQL_Token $current Current token. + * @return bool Whether to add a space. + */ + private function generated_default_metadata_tokens_need_space( ?WP_MySQL_Token $previous, WP_MySQL_Token $current ): bool { + if ( null === $previous ) { + return false; + } + + if ( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $current->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $previous->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous->id + ) { + return false; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $current->id + && $this->is_generated_default_function_like_token( $previous ) + ) { + return false; + } + + return true; + } + + /** + * Check whether a token can be followed by function-call parentheses. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is function-like. + */ + private function is_generated_default_function_like_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::IDENTIFIER, + WP_MySQL_Lexer::NOW_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ); + } + + /** + * Get MySQL column extra metadata. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @param bool $is_serial Whether the data type implies AUTO_INCREMENT. + * @return string Extra metadata. + */ + private function get_column_extra_metadata( WP_Parser_Node $field_definition, bool $is_serial ): string { + $extras = array(); + if ( $is_serial || $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + $extras[] = 'auto_increment'; + } + + if ( $this->field_definition_has_generated_default( $field_definition ) ) { + $extras[] = 'DEFAULT_GENERATED'; + } + + if ( $this->field_definition_has_on_update_current_timestamp( $field_definition ) ) { + $extras[] = 'on update CURRENT_TIMESTAMP'; + } + + return implode( ' ', $extras ); + } + + /** + * Check whether a field definition has a generated default expression. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether the default should be reported as generated metadata. + */ + private function field_definition_has_generated_default( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( + $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) + && $this->is_generated_default_attribute( $attribute ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a field definition has ON UPDATE CURRENT_TIMESTAMP. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether ON UPDATE CURRENT_TIMESTAMP is present. + */ + private function field_definition_has_on_update_current_timestamp( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $this->tokens_have_on_update_current_timestamp( $attribute->get_descendant_tokens() ) ) { + return true; + } + } + + return $this->tokens_have_on_update_current_timestamp( $field_definition->get_descendant_tokens() ); + } + + /** + * Check whether a token stream contains ON UPDATE CURRENT_TIMESTAMP. + * + * @param WP_MySQL_Token[] $tokens Token stream. + * @return bool Whether ON UPDATE CURRENT_TIMESTAMP is present. + */ + private function tokens_have_on_update_current_timestamp( array $tokens ): bool { + for ( $i = 0; $i + 2 < count( $tokens ); ++$i ) { + if ( + WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $i + 1 ]->id + && $this->is_current_timestamp_token( $tokens[ $i + 2 ] ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a DEFAULT attribute is generated rather than a literal. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return bool Whether the default is generated. + */ + private function is_generated_default_attribute( WP_Parser_Node $attribute ): bool { + $tokens = $this->get_default_attribute_value_tokens( $attribute ); + if ( empty( $tokens ) ) { + return false; + } + + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + || $this->is_current_timestamp_default_attribute( $attribute ); + } + + /** + * Check whether a DEFAULT attribute is CURRENT_TIMESTAMP/NOW. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return bool Whether the default is a current timestamp expression. + */ + private function is_current_timestamp_default_attribute( WP_Parser_Node $attribute ): bool { + return null !== $this->get_current_timestamp_default_metadata( $attribute ); + } + + /** + * Get metadata for a CURRENT_TIMESTAMP/NOW default attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return string|null MySQL-facing default metadata, or null. + */ + private function get_current_timestamp_default_metadata( WP_Parser_Node $attribute ): ?string { + $data = $this->get_current_timestamp_default_data( $attribute ); + return null === $data ? null : $data['metadata']; + } + + /** + * Get metadata and precision data for a CURRENT_TIMESTAMP/NOW default attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return array{metadata: string, fsp: int}|null MySQL-facing default metadata and precision, or null. + */ + private function get_current_timestamp_default_data( WP_Parser_Node $attribute ): ?array { + $tokens = $this->strip_default_attribute_outer_parentheses( + $this->get_default_attribute_value_tokens( $attribute ) + ); + $count = count( $tokens ); + + if ( 1 === $count && $this->is_current_timestamp_token( $tokens[0] ) ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $count + && $this->is_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $count + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'now()', + 'fsp' => 0, + ); + } + + if ( + 4 === $count + && $this->is_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'CURRENT_TIMESTAMP(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + if ( + 4 === $count + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'now(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + return null; + } + + /** + * Check whether a token represents CURRENT_TIMESTAMP. + * + * @param WP_MySQL_Token $token Token. + * @return bool Whether the token is CURRENT_TIMESTAMP. + */ + private function is_current_timestamp_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL === $token->id + || ( + WP_MySQL_Lexer::NOW_SYMBOL === $token->id + && 'CURRENT_TIMESTAMP' === strtoupper( $token->get_value() ) + ); + } + + /** + * Get a bounded MySQL fractional seconds precision from a token. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return int|null Precision, or null when unsupported. + */ + private function get_mysql_fractional_seconds_precision_token_value( ?WP_MySQL_Token $token ): ?int { + if ( null === $token || WP_MySQL_Lexer::INT_NUMBER !== $token->id ) { + return null; + } + + $value = trim( $token->get_value() ); + return 1 === preg_match( '/^[0-6]$/', $value ) ? (int) $value : null; + } + + /** + * Build a PostgreSQL current timestamp expression with optional precision. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_current_timestamp_expression_sql( int $fsp ): string { + return 0 === $fsp + ? "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" + : sprintf( "CURRENT_TIMESTAMP(%d) AT TIME ZONE 'UTC'", $fsp ); + } + + /** + * Format a temporal expression as MySQL-compatible text. + * + * @param string $expression_sql PostgreSQL temporal expression SQL. + * @param string $format PostgreSQL TO_CHAR format. + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_temporal_expression_sql( string $expression_sql, string $format, int $fsp ): string { + if ( 0 === $fsp ) { + return sprintf( "TO_CHAR(%s, '%s')", $expression_sql, $format ); + } + + $output_prefix_lengths = array( + 'YYYY-MM-DD HH24:MI:SS' => 20, + 'HH24:MI:SS' => 9, + ); + + return sprintf( + "LEFT(TO_CHAR(%s, '%s.US'), %d)", + $expression_sql, + $format, + ( $output_prefix_lengths[ $format ] ?? 0 ) + $fsp + ); + } + + /** + * Format the emulated MySQL current timestamp default. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_current_timestamp_sql( int $fsp ): string { + return $this->get_postgresql_mysql_temporal_expression_sql( + $this->get_postgresql_current_timestamp_expression_sql( $fsp ), + 'YYYY-MM-DD HH24:MI:SS', + $fsp + ); + } + + /** + * Get DEFAULT attribute value tokens without the DEFAULT keyword. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return WP_MySQL_Token[] Value tokens. + */ + private function get_default_attribute_value_tokens( WP_Parser_Node $attribute ): array { + $value_tokens = array(); + $seen_default = false; + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( ! $seen_default ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + $seen_default = true; + } + continue; + } + + $value_tokens[] = $token; + } + + return $value_tokens; + } + + /** + * Strip simple wrapping parentheses from DEFAULT value tokens. + * + * @param WP_MySQL_Token[] $tokens Value tokens. + * @return WP_MySQL_Token[] Unwrapped tokens. + */ + private function strip_default_attribute_outer_parentheses( array $tokens ): array { + while ( + count( $tokens ) >= 2 + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ count( $tokens ) - 1 ]->id + ) { + $depth = 0; + $wraps_all = true; + $last_index = count( $tokens ) - 1; + foreach ( $tokens as $index => $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + if ( 0 === $depth && $index < $last_index ) { + $wraps_all = false; + break; + } + } + } + + if ( ! $wraps_all || 0 !== $depth ) { + break; + } + + $tokens = array_slice( $tokens, 1, -1 ); + } + + return $tokens; + } + + /** + * Check whether a token represents an unquoted MySQL NULL literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is unquoted NULL. + */ + private function is_unquoted_mysql_null_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $token->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $token->id + && WP_MySQL_Lexer::BACK_TICK_QUOTED_ID !== $token->id + && 0 === strcasecmp( $token->get_value(), 'null' ); + } + + /** + * Extract table default charset and collation. + * + * @param WP_Parser_Node $create_table Create table node. + * @return array{string, string} Charset and collation. + */ + private function get_table_charset_and_collation( WP_Parser_Node $create_table ): array { + $charset = 'utf8mb4'; + $collation = null; + + foreach ( $create_table->get_child_nodes( 'createTableOptions' ) as $options ) { + foreach ( $options->get_child_nodes( 'createTableOption' ) as $option ) { + $default_charset = $option->get_first_child_node( 'defaultCharset' ); + if ( $default_charset ) { + $charset_name = $default_charset->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } + } + + $default_collation = $option->get_first_child_node( 'defaultCollation' ); + if ( $default_collation ) { + $collation_name = $default_collation->get_first_child_node( 'collationName' ); + if ( $collation_name ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_name ) ); + } + } + } + } + + if ( null === $collation ) { + $collation = $this->get_default_collation_for_charset( $charset ); + } else { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Extract MySQL column charset and collation metadata. + * + * @param WP_Parser_Node|null $field_definition Field definition node. + * @param string $data_type Column data type. + * @param string $table_charset Table default charset. + * @param string $table_collation Table default collation. + * @return array{string|null, string|null} Charset and collation. + */ + private function get_column_charset_and_collation( ?WP_Parser_Node $field_definition, string $data_type, string $table_charset, string $table_collation ): array { + if ( ! $field_definition || ! $this->is_mysql_character_data_type( $data_type ) ) { + return array( null, null ); + } + + $charset = null; + $collation = null; + $is_binary = false; + $is_national = $this->is_national_character_data_type( $field_definition->get_first_child_node( 'dataType' ) ); + + $charset_node = $field_definition->get_first_descendant_node( 'charsetWithOptBinary' ); + if ( $charset_node ) { + $charset_name = $charset_node->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::ASCII_SYMBOL ) ) { + $charset = 'latin1'; + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::UNICODE_SYMBOL ) ) { + $charset = 'ucs2'; + } + + if ( $charset_node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { + $is_binary = true; + } + } + + $collation_node = $field_definition->get_first_descendant_node( 'collationName' ); + if ( $collation_node ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_node ) ); + } + + if ( null === $charset && null === $collation && $is_national ) { + $charset = 'utf8'; + $collation = $this->get_default_collation_for_charset( $charset ); + } elseif ( null === $charset && null === $collation ) { + $charset = $table_charset; + $collation = $table_collation; + } elseif ( null === $collation ) { + $collation = $is_binary ? $charset . '_bin' : $this->get_default_collation_for_charset( $charset ); + } elseif ( null === $charset ) { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Get a MySQL column type for metadata. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param WP_Parser_Node|null $field_definition Field definition node. + * @return string MySQL column type. + */ + private function get_mysql_column_type( ?WP_Parser_Node $data_type, ?WP_Parser_Node $field_definition = null ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type = $this->get_normalized_mysql_data_type( $data_type ); + if ( 'integer' === $type ) { + $type = 'int'; + } + + if ( 'serial' === $type ) { + return 'bigint unsigned'; + } + + if ( in_array( $type, array( 'enum', 'set' ), true ) ) { + return $this->get_enum_or_set_column_type( $type, $data_type ); + } + + $numeric_precision = $this->get_numeric_precision_fragment( $data_type ); + if ( '' !== $numeric_precision && in_array( $type, array( 'dec', 'decimal', 'double', 'fixed', 'float', 'numeric' ), true ) ) { + $type .= $numeric_precision; + } + if ( in_array( $type, array( 'datetime', 'time', 'timestamp' ), true ) ) { + $temporal_precision = $numeric_precision; + if ( '' === $temporal_precision ) { + $temporal_precision = $this->get_temporal_precision_fragment( $data_type ); + } + if ( '' !== $temporal_precision ) { + $type .= $temporal_precision; + } + } + + $length = $this->get_field_length( $data_type ); + if ( null === $length && in_array( $type, array( 'binary', 'bit', 'char' ), true ) ) { + $length = 1; + } + if ( null !== $length && in_array( $type, array( 'bigint', 'binary', 'bit', 'char', 'int', 'int1', 'int2', 'int3', 'int4', 'int8', 'mediumint', 'smallint', 'tinyint', 'varbinary', 'varchar' ), true ) ) { + $type = sprintf( '%s(%d)', $type, $length ); + } + + if ( $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::UNSIGNED_SYMBOL ) ) { + $type .= ' unsigned'; + } + + return $type; + } + + /** + * Get a normalized MySQL data type name from a dataType node. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Normalized type name. + */ + private function get_normalized_mysql_data_type( WP_Parser_Node $data_type ): string { + if ( $this->is_serial_data_type( $data_type ) ) { + return 'serial'; + } + + $long_alias = $this->get_normalized_mysql_long_data_type( $data_type ); + if ( null !== $long_alias ) { + return $long_alias; + } + + $character_alias = $this->get_normalized_mysql_character_data_type( $data_type ); + if ( null !== $character_alias ) { + return $character_alias; + } + + $type_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + return strtolower( $type_token->get_value() ); + } + + /** + * Normalize MySQL LONG-prefixed data type aliases. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string|null Normalized data type, or null for non-LONG aliases. + */ + private function get_normalized_mysql_long_data_type( WP_Parser_Node $data_type ): ?string { + $tokens = $data_type->get_descendant_tokens(); + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::LONG_SYMBOL !== $tokens[0]->id + ) { + return null; + } + + if ( ! isset( $tokens[1] ) ) { + return 'mediumtext'; + } + + if ( in_array( $tokens[1]->id, array( WP_MySQL_Lexer::BYTE_SYMBOL, WP_MySQL_Lexer::VARBINARY_SYMBOL ), true ) ) { + return 'mediumblob'; + } + + if ( in_array( $tokens[1]->id, array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL ), true ) ) { + return 'mediumtext'; + } + + return null; + } + + /** + * Normalize MySQL character data type aliases. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string|null Normalized character type, or null for non-character types. + */ + private function get_normalized_mysql_character_data_type( WP_Parser_Node $data_type ): ?string { + $tokens = $data_type->get_descendant_tokens(); + if ( empty( $tokens ) ) { + return null; + } + + $first_id = $tokens[0]->id; + $has_varchar = false; + $has_varying = false; + foreach ( $tokens as $token ) { + if ( in_array( $token->id, array( WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL, WP_MySQL_Lexer::NVARCHAR_SYMBOL ), true ) ) { + $has_varchar = true; + } + if ( WP_MySQL_Lexer::VARYING_SYMBOL === $token->id ) { + $has_varying = true; + } + } + + if ( + $has_varchar + || $has_varying + || in_array( $first_id, array( WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL, WP_MySQL_Lexer::NVARCHAR_SYMBOL ), true ) + ) { + return 'varchar'; + } + + if ( in_array( $first_id, array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::NCHAR_SYMBOL, WP_MySQL_Lexer::NATIONAL_SYMBOL ), true ) ) { + return 'char'; + } + + return null; + } + + /** + * Check whether a data type is SERIAL. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @return bool Whether SERIAL is present. + */ + private function is_serial_data_type( ?WP_Parser_Node $data_type ): bool { + return $data_type && null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::SERIAL_SYMBOL ); + } + + /** + * Extract PostgreSQL string literals from a normalized MySQL ENUM column type. + * + * @param string $mysql_column_type MySQL-facing ENUM column type. + * @return string[] PostgreSQL quoted enum labels. + */ + private function get_postgresql_mysql_enum_labels( string $mysql_column_type ): array { + if ( 1 > preg_match_all( "/'((?:''|[^'])*)'/", $mysql_column_type, $matches ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL ENUM column type for PostgreSQL install DDL.' ); + } + + $labels = array(); + foreach ( $matches[1] as $label ) { + $labels[] = $this->quote_string_literal( str_replace( "''", "'", $label ) ); + } + + return $labels; + } + + /** + * Check whether a data type uses MySQL's national character set aliases. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @return bool Whether the data type is national character based. + */ + private function is_national_character_data_type( ?WP_Parser_Node $data_type ): bool { + return $data_type && ( + null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NATIONAL_SYMBOL ) + || null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NCHAR_SYMBOL ) + || null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NVARCHAR_SYMBOL ) + ); + } + + /** + * Get the MySQL COLUMN_TYPE metadata for ENUM and SET columns. + * + * @param string $type Base MySQL data type. + * @param WP_Parser_Node $data_type Data type node. + * @return string MySQL column type. + */ + private function get_enum_or_set_column_type( string $type, WP_Parser_Node $data_type ): string { + $values = array(); + foreach ( $data_type->get_descendant_tokens() as $token ) { + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + ) { + $values[] = $this->quote_string_literal( $token->get_value() ); + } + } + + if ( empty( $values ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); + } + + return sprintf( '%s(%s)', $type, implode( ',', $values ) ); + } + + /** + * Get the base MySQL column type without length. + * + * @param string $column_type Column type. + * @return string Base type. + */ + private function get_base_mysql_column_type( string $column_type ): string { + $length_position = strpos( $column_type, '(' ); + if ( false === $length_position ) { + return strtolower( $column_type ); + } + + return strtolower( substr( $column_type, 0, $length_position ) ); + } + + /** + * Check whether a MySQL type has character set metadata. + * + * @param string $data_type Base MySQL data type. + * @return bool Whether the type is textual. + */ + private function is_mysql_character_data_type( string $data_type ): bool { + return in_array( + $data_type, + array( 'char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set' ), + true + ); + } + + /** + * Check whether a MySQL column type is spatial. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is spatial. + */ + private function is_mysql_spatial_column_type( string $data_type ): bool { + $base_type = $this->get_base_mysql_column_type( $data_type ); + return in_array( + $base_type, + array( + 'geometry', + 'point', + 'linestring', + 'polygon', + 'multipoint', + 'multilinestring', + 'multipolygon', + 'geometrycollection', + 'geomcollection', + ), + true + ); + } + + /** + * Normalize MySQL charset names for WordPress metadata expectations. + * + * @param string $charset Charset name. + * @return string Normalized charset. + */ + private function normalize_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Normalize MySQL collation names for WordPress metadata expectations. + * + * @param string $collation Collation name. + * @return string Normalized collation. + */ + private function normalize_collation( string $collation ): string { + $collation = strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $collation, 'utf8mb3_' ) ) { + return 'utf8_' . substr( $collation, strlen( 'utf8mb3_' ) ); + } + + return $collation; + } + + /** + * Get the charset prefix from a collation name. + * + * @param string $collation Collation name. + * @return string Charset name. + */ + private function get_charset_from_collation( string $collation ): string { + $underscore = strpos( $collation, '_' ); + if ( false === $underscore ) { + return $this->normalize_charset( $collation ); + } + + return $this->normalize_charset( substr( $collation, 0, $underscore ) ); + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset name. + * @return string Collation name. + */ + private function get_default_collation_for_charset( string $charset ): string { + $charset = $this->normalize_charset( $charset ); + if ( isset( self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ) ) { + return self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ]; + } + + return $charset . '_general_ci'; + } + + /** + * Serialize a parser node value. + * + * @param WP_Parser_Node $node Parser node. + * @return string Node value. + */ + private function get_node_value( WP_Parser_Node $node ): string { + $value = ''; + foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_Parser_Node ) { + $value .= $this->get_node_value( $child ); + } else { + $value .= $child->get_value(); + } + } + + return $value; + } + + /** + * Get a numeric field length. + * + * @param WP_Parser_Node $node Node that may contain a fieldLength child. + * @return int|null Field length. + */ + private function get_field_length( WP_Parser_Node $node ): ?int { + $field_length = $node->get_first_child_node( 'fieldLength' ); + if ( ! $field_length ) { + return null; + } + + $token = $field_length->get_first_descendant_token( WP_MySQL_Lexer::INT_NUMBER ); + return $token ? (int) $token->get_value() : null; + } + + /** + * Get a numeric precision/scale SQL fragment. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Precision fragment, including parentheses, or empty string. + */ + private function get_numeric_precision_fragment( WP_Parser_Node $data_type ): string { + $precision = $data_type->get_first_descendant_node( 'precision' ); + if ( $precision ) { + return $this->get_node_value( $precision ); + } + + $field_length = $data_type->get_first_descendant_node( 'fieldLength' ); + return $field_length ? $this->get_node_value( $field_length ) : ''; + } + + /** + * Get a temporal fractional seconds precision SQL fragment. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Precision fragment, including parentheses, or empty string. + */ + private function get_temporal_precision_fragment( WP_Parser_Node $data_type ): string { + $tokens = $data_type->get_descendant_tokens(); + for ( $i = 0; $i + 2 < count( $tokens ); ++$i ) { + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id + && WP_MySQL_Lexer::INT_NUMBER === $tokens[ $i + 1 ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i + 2 ]->id + ) { + $fsp = (int) $tokens[ $i + 1 ]->get_value(); + return $fsp >= 0 && $fsp <= 6 ? sprintf( '(%d)', $fsp ) : ''; + } + } + + return ''; + } + + /** + * Quote a PostgreSQL identifier. + * + * @param string $identifier Identifier. + * @return string Quoted identifier. + */ + private function quote_identifier( string $identifier ): string { + return WP_PostgreSQL_Connection::quote_identifier_value( $identifier ); + } + + /** + * Quote a PostgreSQL string literal. + * + * @param string $value Literal value. + * @return string Quoted literal. + */ + private function quote_string_literal( string $value ): string { + if ( false !== strpos( $value, "\0" ) ) { + throw new InvalidArgumentException( 'PostgreSQL string literals cannot contain NUL bytes.' ); + } + + return "'" . str_replace( "'", "''", $value ) . "'"; + } +} diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php new file mode 100644 index 000000000..47e8cc06e --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -0,0 +1,30771 @@ + array( + 'REAL_AS_FLOAT', + 'PIPES_AS_CONCAT', + 'ANSI_QUOTES', + 'IGNORE_SPACE', + 'ONLY_FULL_GROUP_BY', + ), + 'TRADITIONAL' => array( + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'NO_ZERO_IN_DATE', + 'NO_ZERO_DATE', + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + ), + ); + + private const MYSQL_SYSTEM_VARIABLE_SCOPE_TOKENS = array( WP_MySQL_Lexer::GLOBAL_SYMBOL, WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::SESSION_SYMBOL ); + + private const MYSQL_INVALID_SYSTEM_VARIABLE_NAME_TOKENS = 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 ); + + private const MYSQL_DIRECT_INFORMATION_SCHEMA_IDENTIFIER_STOP_TOKENS = array( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::SEMICOLON_SYMBOL ); + private const MYSQL_DIRECT_INFORMATION_SCHEMA_SOURCE_END_TOKENS = array( WP_MySQL_Lexer::WHERE_SYMBOL, WP_MySQL_Lexer::GROUP_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL, WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL ); + private const MYSQL_DIRECT_INFORMATION_SCHEMA_SELECT_TAIL_CLAUSE_TOKENS = array( WP_MySQL_Lexer::WHERE_SYMBOL, WP_MySQL_Lexer::GROUP_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL ); + private const MYSQL_INFORMATION_SCHEMA_BACKEND_CONTEXT_REJECT_TOKENS = array( WP_MySQL_Lexer::EXPLAIN_SYMBOL, WP_MySQL_Lexer::WITH_SYMBOL ); + private const MYSQL_INFORMATION_SCHEMA_BACKEND_WRITE_ADMIN_REJECT_TOKENS = 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 ); + + private const MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_INVALID_NEXT_TOKEN_IDS = array( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::SEMICOLON_SYMBOL ); + private const MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_INVALID_PREVIOUS_TOKEN_IDS = array( WP_MySQL_Lexer::AS_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::USING_SYMBOL ); + private const MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_VALID_PREVIOUS_TOKEN_IDS = array( WP_MySQL_Lexer::AND_SYMBOL, WP_MySQL_Lexer::BETWEEN_SYMBOL, WP_MySQL_Lexer::BY_SYMBOL, WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, WP_MySQL_Lexer::GREATER_THAN_OPERATOR, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, WP_MySQL_Lexer::LESS_THAN_OPERATOR, WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, WP_MySQL_Lexer::OR_SYMBOL, WP_MySQL_Lexer::REGEXP_SYMBOL, WP_MySQL_Lexer::WHERE_SYMBOL, WP_MySQL_Lexer::XOR_SYMBOL ); + + private const MYSQL_SET_ASSIGNMENT_VALUE_BOUNDARY_TOKENS = array( WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::SEMICOLON_SYMBOL ); + + private const MYSQL_SET_LITERAL_DISALLOWED_TOKENS = array( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL, WP_MySQL_Lexer::AT_SIGN_SYMBOL, WP_MySQL_Lexer::AT_TEXT_SUFFIX, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::SEMICOLON_SYMBOL ); + + private const MYSQL_UNSIGNED_INTEGER_TOKENS = array( WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LONG_NUMBER, WP_MySQL_Lexer::ULONGLONG_NUMBER ); + private const MYSQL_NUMERIC_LITERAL_TOKENS = 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 ); + private const MYSQL_BOOLEAN_PREDICATE_LEFT_BOUNDARY_TOKENS = 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 ); + private const MYSQL_BOOLEAN_PREDICATE_RIGHT_BOUNDARY_TOKENS = array( WP_MySQL_Lexer::AND_SYMBOL, WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, WP_MySQL_Lexer::OR_SYMBOL, WP_MySQL_Lexer::XOR_SYMBOL ); + private const MYSQL_TEMPORAL_COMPARISON_PREDICATE_BOUNDARY_TOKENS = array( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::LOGICAL_AND_OPERATOR, WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, WP_MySQL_Lexer::ON_SYMBOL ); + private const MYSQL_DBDELTA_ADD_INDEX_ACTION_TOKENS = array( WP_MySQL_Lexer::FULLTEXT_SYMBOL, WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL, WP_MySQL_Lexer::PRIMARY_SYMBOL, WP_MySQL_Lexer::SPATIAL_SYMBOL, WP_MySQL_Lexer::UNIQUE_SYMBOL ); + private const MYSQL_DBDELTA_ADD_CONSTRAINT_ACTION_TOKENS = array( WP_MySQL_Lexer::CHECK_SYMBOL, WP_MySQL_Lexer::CONSTRAINT_SYMBOL, WP_MySQL_Lexer::FOREIGN_SYMBOL ); + private const MYSQL_DML_VALUES_ROW_LIST_KEYWORD_TOKENS = array( WP_MySQL_Lexer::VALUES_SYMBOL, WP_MySQL_Lexer::VALUE_SYMBOL ); + private const MYSQL_DML_SELECT_SOURCE_TOKENS = array( WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::OPEN_PAR_SYMBOL ); + private const MYSQL_UPSERT_CONFLICT_PROBE_LITERAL_TOKENS = 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 ); + private const MYSQL_SHOW_OUTPUT_COLUMNS = array( + 'character_set' => array( 'Charset', 'Description', 'Default collation', 'Maxlen' ), + 'collation' => array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + 'columns' => array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + 'columns_full' => array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), + 'databases' => array( 'Database' ), + 'engines' => array( 'Engine', 'Support', 'Comment', 'Transactions', 'XA', 'Savepoints' ), + 'events' => array( 'Db', 'Name', 'Definer', 'Time zone', 'Type', 'Execute at', 'Interval value', 'Interval field', 'Starts', 'Ends', 'Status', 'Originator', 'character_set_client', 'collation_connection', 'Database Collation' ), + 'index' => array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Collation', 'Cardinality', 'Sub_part', 'Packed', 'Null', 'Index_type', 'Comment', 'Index_comment', 'Visible', 'Expression' ), + 'name_value' => array( 'Variable_name', 'Value' ), + 'open_tables' => array( 'Database', 'Table', 'In_use', 'Name_locked' ), + 'plugins' => array( 'Name', 'Status', 'Type', 'Library', 'License' ), + 'processlist' => array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), + 'routine_status' => array( 'Db', 'Name', 'Type', 'Definer', 'Modified', 'Created', 'Security_type', 'Comment', 'character_set_client', 'collation_connection', 'Database Collation' ), + 'table_status' => array( 'Name', 'Engine', 'Version', 'Row_format', 'Rows', 'Avg_row_length', 'Data_length', 'Max_data_length', 'Index_length', 'Data_free', 'Auto_increment', 'Create_time', 'Update_time', 'Check_time', 'Collation', 'Checksum', 'Create_options', 'Comment' ), + 'triggers' => array( 'Trigger', 'Event', 'Table', 'Statement', 'Timing', 'Created', 'sql_mode', 'Definer', 'character_set_client', 'collation_connection', 'Database Collation' ), + ); + private const MYSQL_SHOW_TABLE_STATUS_NUMERIC_COLUMNS = array( 'Version', 'Rows', 'Avg_row_length', 'Data_length', 'Max_data_length', 'Index_length', 'Data_free', 'Auto_increment', 'Checksum' ); + private const MYSQL_SHOW_WHERE_COMPARISON_OPERATORS = array( WP_MySQL_Lexer::EQUAL_OPERATOR => '=' ) + array( WP_MySQL_Lexer::NOT_EQUAL_OPERATOR => '<>' ) + array( WP_MySQL_Lexer::GREATER_THAN_OPERATOR => '>' ) + array( WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR => '>=' ) + array( WP_MySQL_Lexer::LESS_THAN_OPERATOR => '<' ) + array( WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR => '<=' ) + array( WP_MySQL_Lexer::NULL_SAFE_EQUAL_OPERATOR => '<=>' ); + private const MYSQL_SHOW_WHERE_ADDITIVE_OPERATORS = array( WP_MySQL_Lexer::PLUS_OPERATOR => '+' ) + array( WP_MySQL_Lexer::MINUS_OPERATOR => '-' ); + private const MYSQL_SHOW_WHERE_MULTIPLICATIVE_OPERATORS = array( WP_MySQL_Lexer::MULT_OPERATOR => '*' ) + array( WP_MySQL_Lexer::DIV_OPERATOR => '/' ) + array( WP_MySQL_Lexer::DIV_SYMBOL => '/' ) + array( WP_MySQL_Lexer::MOD_OPERATOR => '%' ) + array( WP_MySQL_Lexer::MOD_SYMBOL => '%' ); + private const MYSQL_SHOW_WHERE_KEYWORD_FUNCTIONS = array( WP_MySQL_Lexer::LEFT_SYMBOL => 'left' ) + array( WP_MySQL_Lexer::MOD_SYMBOL => 'mod' ) + array( WP_MySQL_Lexer::RIGHT_SYMBOL => 'right' ) + array( WP_MySQL_Lexer::SUBSTR_SYMBOL => 'substr' ) + array( WP_MySQL_Lexer::SUBSTRING_SYMBOL => 'substring' ); + private const MYSQL_SHOW_OUTPUT_COLUMN_KEYWORD_TOKENS = array( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, WP_MySQL_Lexer::CHARSET_SYMBOL, WP_MySQL_Lexer::COLLATION_SYMBOL, WP_MySQL_Lexer::COLUMN_NAME_SYMBOL, WP_MySQL_Lexer::COMMENT_SYMBOL, WP_MySQL_Lexer::CHECKSUM_SYMBOL, WP_MySQL_Lexer::DATABASE_SYMBOL, WP_MySQL_Lexer::DEFAULT_SYMBOL, WP_MySQL_Lexer::DEFINER_SYMBOL, WP_MySQL_Lexer::ENGINE_SYMBOL, WP_MySQL_Lexer::ENDS_SYMBOL, WP_MySQL_Lexer::EVENT_SYMBOL, WP_MySQL_Lexer::EXECUTE_SYMBOL, WP_MySQL_Lexer::HOST_SYMBOL, WP_MySQL_Lexer::INTERVAL_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL, WP_MySQL_Lexer::NAME_SYMBOL, WP_MySQL_Lexer::NULL_SYMBOL, WP_MySQL_Lexer::PRIVILEGES_SYMBOL, WP_MySQL_Lexer::ROWS_SYMBOL, WP_MySQL_Lexer::STARTS_SYMBOL, WP_MySQL_Lexer::STATUS_SYMBOL, WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TIME_SYMBOL, WP_MySQL_Lexer::TRIGGER_SYMBOL, WP_MySQL_Lexer::TYPE_SYMBOL, WP_MySQL_Lexer::USER_SYMBOL, WP_MySQL_Lexer::VALUE_SYMBOL, WP_MySQL_Lexer::VISIBLE_SYMBOL, WP_MySQL_Lexer::ZONE_SYMBOL ); + + private const MYSQL_UNSUPPORTED_STATEMENT_MESSAGE_DESCRIPTORS = array( array( 'administration', WP_MySQL_Lexer::SHOW_SYMBOL, 'Unsupported SHOW statement.' ), array( 'administration', WP_MySQL_Lexer::CHECKSUM_SYMBOL, 'Unsupported CHECKSUM TABLE statement.' ), array( 'administration', WP_MySQL_Lexer::FLUSH_SYMBOL, 'Unsupported FLUSH statement.' ), array( 'administration', WP_MySQL_Lexer::KILL_SYMBOL, 'Unsupported KILL statement.' ), array( 'administration', WP_MySQL_Lexer::CACHE_SYMBOL, 'Unsupported CACHE INDEX statement.' ), array( 'administration', WP_MySQL_Lexer::LOAD_SYMBOL, 'Unsupported LOAD statement.' ), array( 'administration', WP_MySQL_Lexer::BINLOG_SYMBOL, 'Unsupported BINLOG statement.' ), array( 'administration', WP_MySQL_Lexer::SHUTDOWN_SYMBOL, 'Unsupported SHUTDOWN statement.' ), array( 'administration', WP_MySQL_Lexer::GRANT_SYMBOL, 'Unsupported GRANT statement.' ), array( 'administration', WP_MySQL_Lexer::REVOKE_SYMBOL, 'Unsupported REVOKE statement.' ), array( 'administration', WP_MySQL_Lexer::RESET_SYMBOL, 'Unsupported RESET statement.' ), array( 'administration', WP_MySQL_Lexer::PURGE_SYMBOL, 'Unsupported PURGE statement.' ), array( 'administration', WP_MySQL_Lexer::INSTALL_SYMBOL, 'Unsupported INSTALL statement.' ), array( 'administration', WP_MySQL_Lexer::UNINSTALL_SYMBOL, 'Unsupported UNINSTALL statement.' ), array( 'administration', WP_MySQL_Lexer::ANALYZE_SYMBOL, 'Unsupported table administration statement.' ), array( 'administration', WP_MySQL_Lexer::CHECK_SYMBOL, 'Unsupported table administration statement.' ), array( 'administration', WP_MySQL_Lexer::OPTIMIZE_SYMBOL, 'Unsupported table administration statement.' ), array( 'administration', WP_MySQL_Lexer::REPAIR_SYMBOL, 'Unsupported table administration statement.' ), array( 'alter', WP_MySQL_Lexer::DATABASE_SYMBOL, 'Unsupported ALTER DATABASE statement.' ), array( 'alter', WP_MySQL_Lexer::EVENT_SYMBOL, 'Unsupported ALTER EVENT statement.' ), array( 'alter', WP_MySQL_Lexer::LOGFILE_SYMBOL, 'Unsupported ALTER LOGFILE statement.' ), array( 'alter', WP_MySQL_Lexer::SERVER_SYMBOL, 'Unsupported ALTER SERVER statement.' ), array( 'alter', WP_MySQL_Lexer::TABLESPACE_SYMBOL, 'Unsupported ALTER TABLESPACE statement.' ), array( 'alter', WP_MySQL_Lexer::UNDO_SYMBOL, 'Unsupported ALTER UNDO TABLESPACE statement.' ), array( 'alter', WP_MySQL_Lexer::USER_SYMBOL, 'Unsupported ALTER USER statement.' ), array( 'alter', WP_MySQL_Lexer::VIEW_SYMBOL, 'Unsupported ALTER VIEW statement.' ), array( 'create', WP_MySQL_Lexer::DATABASE_SYMBOL, 'Unsupported CREATE DATABASE statement.' ), array( 'create', WP_MySQL_Lexer::SCHEMA_SYMBOL, 'Unsupported CREATE DATABASE statement.' ), array( 'create', WP_MySQL_Lexer::VIEW_SYMBOL, 'Unsupported CREATE VIEW statement.' ), array( 'create', WP_MySQL_Lexer::PROCEDURE_SYMBOL, 'Unsupported CREATE PROCEDURE statement.' ), array( 'create', WP_MySQL_Lexer::FUNCTION_SYMBOL, 'Unsupported CREATE FUNCTION statement.' ), array( 'create', WP_MySQL_Lexer::TRIGGER_SYMBOL, 'Unsupported CREATE TRIGGER statement.' ), array( 'create', WP_MySQL_Lexer::EVENT_SYMBOL, 'Unsupported CREATE EVENT statement.' ), array( 'create', WP_MySQL_Lexer::USER_SYMBOL, 'Unsupported CREATE USER statement.' ), array( 'create', WP_MySQL_Lexer::ROLE_SYMBOL, 'Unsupported CREATE ROLE statement.' ), array( 'create', WP_MySQL_Lexer::SERVER_SYMBOL, 'Unsupported CREATE SERVER statement.' ), array( 'create', WP_MySQL_Lexer::LOGFILE_SYMBOL, 'Unsupported CREATE LOGFILE statement.' ), array( 'create', WP_MySQL_Lexer::TABLESPACE_SYMBOL, 'Unsupported CREATE TABLESPACE statement.' ), array( 'create', WP_MySQL_Lexer::INDEX_SYMBOL, 'Unsupported CREATE INDEX statement.' ), array( 'create', WP_MySQL_Lexer::UNIQUE_SYMBOL, 'Unsupported CREATE INDEX statement.' ), array( 'create', WP_MySQL_Lexer::FULLTEXT_SYMBOL, 'Unsupported CREATE INDEX statement.' ), array( 'create', WP_MySQL_Lexer::SPATIAL_SYMBOL, 'Unsupported CREATE INDEX statement.' ), array( 'drop', WP_MySQL_Lexer::DATABASE_SYMBOL, 'Unsupported DROP DATABASE statement.' ), array( 'drop', WP_MySQL_Lexer::SCHEMA_SYMBOL, 'Unsupported DROP DATABASE statement.' ), array( 'drop', WP_MySQL_Lexer::VIEW_SYMBOL, 'Unsupported DROP VIEW statement.' ), array( 'drop', WP_MySQL_Lexer::PROCEDURE_SYMBOL, 'Unsupported DROP PROCEDURE statement.' ), array( 'drop', WP_MySQL_Lexer::FUNCTION_SYMBOL, 'Unsupported DROP FUNCTION statement.' ), array( 'drop', WP_MySQL_Lexer::TRIGGER_SYMBOL, 'Unsupported DROP TRIGGER statement.' ), array( 'drop', WP_MySQL_Lexer::EVENT_SYMBOL, 'Unsupported DROP EVENT statement.' ), array( 'drop', WP_MySQL_Lexer::USER_SYMBOL, 'Unsupported DROP USER statement.' ), array( 'drop', WP_MySQL_Lexer::ROLE_SYMBOL, 'Unsupported DROP ROLE statement.' ), array( 'drop', WP_MySQL_Lexer::TABLESPACE_SYMBOL, 'Unsupported DROP TABLESPACE statement.' ), array( 'drop', WP_MySQL_Lexer::UNDO_SYMBOL, 'Unsupported DROP UNDO TABLESPACE statement.' ), array( 'drop', WP_MySQL_Lexer::SERVER_SYMBOL, 'Unsupported DROP SERVER statement.' ), array( 'drop', WP_MySQL_Lexer::LOGFILE_SYMBOL, 'Unsupported DROP LOGFILE statement.' ) ); + + private const MYSQL_SPATIAL_REFERENCE_SYSTEM_STATEMENT_TOKENS = array( WP_MySQL_Lexer::SPATIAL_SYMBOL, WP_MySQL_Lexer::REFERENCE_SYMBOL, WP_MySQL_Lexer::SYSTEM_SYMBOL ); + + private const MYSQL_CHARSET_SESSION_VARIABLES = array( 'character_set_client', 'character_set_connection', 'character_set_results', 'character_set_database', 'character_set_server' ); + private const MYSQL_COLLATION_SESSION_VARIABLES = array( 'collation_connection', 'collation_database', 'collation_server' ); + private const MYSQL_BOOLEAN_SYSTEM_VARIABLES = array( 'autocommit', 'big_tables', 'end_markers_in_json', 'explicit_defaults_for_timestamp', 'foreign_key_checks', 'keep_files_on_create', 'log_bin_trust_function_creators', 'old_alter_table', 'print_identified_with_as_hex', 'pseudo_replica_mode', 'pseudo_slave_mode', 'require_row_format', 'select_into_disk_sync', 'session_track_schema', 'session_track_state_change', 'show_create_table_skip_secondary_engine', 'show_create_table_verbosity', 'sql_auto_is_null', 'sql_big_selects', 'sql_buffer_result', 'sql_log_bin', 'sql_notes', 'sql_quote_show_create', 'sql_safe_updates', 'sql_warnings', 'transaction_read_only', 'tx_read_only', 'unique_checks' ); + + private const MYSQL_SESSION_USER = 'root@%'; + + private const MYSQL_CONNECTION_ID = '1'; + + private const MYSQL_SHOW_GRANTS_COLUMN = 'Grants for root@%'; + + private const MYSQL_SHOW_GRANTS_VALUE = 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, ' . + 'PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, ' . + 'EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, ' . + 'CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION'; + + /** + * Prefix for encoded MySQL text bytes PostgreSQL text cannot store directly. + */ + private const MYSQL_TEXT_ENCODING_PREFIX = "\xEE\x80\x80WP_MYSQL_TEXT_V1:"; + + /** + * Hash context for the MySQL text encoding envelope. + */ + private const MYSQL_TEXT_ENCODING_HASH_CONTEXT = 'wp-mysql-text-v1:'; + + /** + * Bit mask for the base PDO fetch style without fetchAll() grouping flags. + */ + private const PDO_FETCH_STYLE_MASK = 0x0f; + + /** + * Hidden column used to carry FOUND_ROWS() accounting with a paged result. + */ + private const SQL_CALC_FOUND_ROWS_WINDOW_COLUMN = '__wp_pg_found_rows'; + + /** + * Maximum number of exact MySQL query translations cached per connection. + */ + private const MYSQL_QUERY_TRANSLATION_CACHE_LIMIT = 256; + + private const MYSQL_TOKEN_SEQUENCE_TRANSLATION_RULES = array( 'translate_mysql_dual_table_reference_to_postgresql', 'translate_mysql_index_hint_to_postgresql', 'translate_mysql_limit_offset_count_to_postgresql', 'translate_mysql_field_function_to_postgresql', 'translate_mysql_typed_cast_or_convert_to_postgresql', 'translate_mysql_regexp_operator_to_postgresql', 'translate_mysql_group_concat_function_to_postgresql', 'translate_mysql_rand_function_to_postgresql', 'translate_mysql_session_user_function_to_postgresql', 'translate_mysql_infix_interval_expression_to_postgresql', 'translate_mysql_date_arithmetic_to_postgresql', 'translate_mysql_nonparenthesized_timestamp_function_to_postgresql', 'translate_mysql_common_function_to_postgresql', 'translate_mysql_week_function_to_postgresql', 'translate_mysql_weekday_index_function_to_postgresql', 'translate_mysql_date_format_to_postgresql', 'translate_mysql_date_time_extract_to_postgresql', 'translate_mysql_convert_using_to_postgresql', 'translate_mysql_variable_reference_to_postgresql:without_end', 'translate_mysql_select_row_locking_clause_to_empty_postgresql' ); + private const MYSQL_COMPATIBLE_REWRITE_STATEMENT_TOKENS = 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 ); + private const MYSQL_COMPATIBLE_REWRITE_UNSUPPORTED_FUNCTION_SCANNERS = array( 'contains_unsupported_mysql_date_arithmetic_function', 'contains_unsupported_mysql_timestampadd_function', 'contains_unsupported_mysql_date_format_function', 'contains_unsupported_mysql_rand_function', 'contains_unsupported_mysql_week_function', 'contains_unsupported_mysql_extract_function', 'contains_unsupported_mysql_convert_function', 'contains_unsupported_mysql_common_function' ); + + private const MYSQL_SELECT_PROJECTION_BOUNDARY_TOKENS = array( WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::FROM_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 ); + private const MYSQL_SELECT_CLAUSE_POSITION_TOKENS = array( 'from' => WP_MySQL_Lexer::FROM_SYMBOL ) + array( 'group' => WP_MySQL_Lexer::GROUP_SYMBOL ) + array( 'having' => WP_MySQL_Lexer::HAVING_SYMBOL ) + array( 'order' => WP_MySQL_Lexer::ORDER_SYMBOL ) + array( 'where' => WP_MySQL_Lexer::WHERE_SYMBOL ); + private const MYSQL_SELECT_FROM_BOUNDARY_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 ); + private const MYSQL_SELECT_GROUP_BOUNDARY_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 ); + private const MYSQL_SELECT_HAVING_BOUNDARY_TOKENS = array( WP_MySQL_Lexer::FOR_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 ); + private const MYSQL_SELECT_ORDER_BOUNDARY_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 ); + private const MYSQL_SELECT_WHERE_BOUNDARY_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 ); + private const MYSQL_SELECT_CLAUSE_END_DESCRIPTORS = array( 'from' => array( 1, self::MYSQL_SELECT_FROM_BOUNDARY_TOKENS ) ) + array( 'group' => array( 1, self::MYSQL_SELECT_GROUP_BOUNDARY_TOKENS ) ) + array( 'having' => array( 1, self::MYSQL_SELECT_HAVING_BOUNDARY_TOKENS ) ) + array( 'order' => array( 2, self::MYSQL_SELECT_ORDER_BOUNDARY_TOKENS ) ) + array( 'where' => array( 1, self::MYSQL_SELECT_WHERE_BOUNDARY_TOKENS ) ); + private const MYSQL_SELECT_ROW_LOCKING_MODE_TOKENS = array( WP_MySQL_Lexer::SHARE_SYMBOL, WP_MySQL_Lexer::UPDATE_SYMBOL ); + private const MYSQL_SIMPLE_SELECT_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 ); + private const MYSQL_SELECT_TRANSLATOR_PIPELINE = array( + array( + 'name' => 'row_locking', + 'guard' => 'has_row_locking_clause', + 'translators' => array( 'translate_mysql_select_row_locking_query' ), + ), + array( + 'name' => 'information_schema_rewrite', + 'guard' => 'requires_information_schema_guard', + 'translators' => array( + 'translate_direct_information_schema_select_query', + 'translate_application_select_with_direct_information_schema_nested_selects', + ), + ), + array( + 'name' => 'information_schema_rejection', + 'guard' => 'requires_information_schema_guard', + 'reject_information_schema' => true, + ), + array( + 'name' => 'aggregate_ordering', + 'guard' => 'may_need_aggregate_order_rewrite', + 'translators' => array( + 'translate_strict_aggregate_grouped_order_by_query', + 'translate_grouped_having_alias_query', + ), + ), + array( + 'name' => 'last_insert_id_assignment', + 'guard' => 'may_assign_last_insert_id', + 'last_insert_id' => true, + ), + array( + 'name' => 'version_function', + 'guard' => 'may_read_version_function', + 'translators' => array( 'translate_mysql_version_function_select_query' ), + ), + array( + 'name' => 'simple_select', + 'guard' => 'may_use_simple_select', + 'translators' => array( 'translate_simple_mysql_select_query' ), + ), + array( + 'name' => 'information_schema_main_database', + 'guard' => 'may_target_main_database_from_information_schema', + 'translators' => array( 'translate_information_schema_main_database_select_query' ), + ), + array( + 'name' => 'distinct_ordering', + 'guard' => 'has_distinct', + 'translators' => array( 'translate_distinct_order_by_query' ), + ), + array( + 'name' => 'sql_calc_found_rows', + 'guard' => 'has_sql_calc_found_rows', + 'translators' => array( 'translate_sql_calc_found_rows_select_query' ), + ), + array( + 'name' => 'compatible_select', + 'translators' => array( 'translate_mysql_compatible_query' ), + ), + ); + private const MYSQL_AGGREGATE_CALL_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 ); + + private const MYSQL_CONSTANT_STRING_FUNCTION_DESCRIPTORS = array( + array( 'lcase lower', 1, 1, 'ascii_unary', 'strtolower' ), + array( 'ucase upper', 1, 1, 'ascii_unary', 'strtoupper' ), + array( 'ltrim', 1, 1, 'ascii_unary', 'ltrim' ), + array( 'rtrim', 1, 1, 'ascii_unary', 'rtrim' ), + array( 'reverse', 1, 1, 'ascii_unary', 'strrev' ), + array( 'coalesce', 1, null, 'coalesce', null ), + array( 'ifnull', 2, 2, 'ifnull', null ), + array( 'elt', 2, null, 'elt', null ), + array( 'concat', 1, null, 'strings', 'concat' ), + array( 'concat_ws', 2, null, 'strings', 'concat_ws' ), + array( 'nullif', 2, 2, 'strings', 'nullif' ), + array( 'replace', 3, 3, 'strings', 'replace' ), + array( 'left right', 2, 2, 'side', null ), + array( 'lpad rpad', 3, 3, 'pad', null ), + array( 'repeat', 2, 2, 'repeat', null ), + array( 'space', 1, 1, 'space', null ), + array( 'substr substring', 2, 3, 'substring', null ), + ); + + private const MYSQL_SIMPLE_COMMON_FUNCTION_REWRITE_DESCRIPTORS = array( + array( 'char_length character_length', 1, 1, 'template', 'CHAR_LENGTH(CAST(%s AS text))' ), + array( 'ifnull', 2, 2, 'template', 'COALESCE(%s, %s)' ), + array( 'instr', 2, 2, 'template', 'STRPOS(CAST(%s AS text), CAST(%s AS text))' ), + array( 'isnull', 1, 1, 'template', 'CASE WHEN %s IS NULL THEN 1 ELSE 0 END' ), + array( 'lcase lower', 1, 1, 'template', 'LOWER(CAST(%s AS text))' ), + array( 'locate locate_2', 2, 2, 'template', 'STRPOS(CAST(%2$s AS text), CAST(%1$s AS text))' ), + array( 'ltrim', 1, 1, 'template', "LTRIM(CAST(%s AS text), ' ')" ), + array( 'md5', 1, 1, 'template', 'MD5(CAST(%s AS text))' ), + array( 'nullif', 2, 2, 'template', 'NULLIF(%s, %s)' ), + array( 'replace', 3, 3, 'template', 'REPLACE(CAST(%s AS text), CAST(%s AS text), CAST(%s AS text))' ), + array( 'reverse', 1, 1, 'template', 'REVERSE(CAST(%s AS text))' ), + array( 'rtrim', 1, 1, 'template', "RTRIM(CAST(%s AS text), ' ')" ), + array( 'ucase upper', 1, 1, 'template', 'UPPER(CAST(%s AS text))' ), + array( 'concat', 1, null, 'cast_join', ' || ' ), + array( 'coalesce', 1, null, 'variadic_template', 'COALESCE(%s)' ), + ); + + private const MYSQL_VIEW_SELECT_VALIDATION_SCANNERS = array( array( 'contains_mysql_index_hint_syntax' ), array( 'contains_unsupported_mysql_date_arithmetic_function_query' ), array( 'contains_unsupported_mysql_fulltext_search_query' ), array( 'contains_unsupported_mysql_range_scanner_query', array( array( 'contains_unsupported_mysql_common_function' ) ) ), array( 'contains_unsupported_mysql_group_concat_function_query' ), array( 'contains_unsupported_mysql_week_function_query' ), array( 'contains_unsupported_mysql_extract_function_query' ) ); + + private const MYSQL_TYPED_CAST_OR_CONVERT_REWRITE_TYPES = array( 'cast' => array( 'integer', 'date_time', 'date', 'binary' ) ) + array( 'convert' => array( 'integer', 'character', 'binary', 'decimal', 'date' ) ); + + private const MYSQL_COMPATIBLE_REWRITE_CALL_MARKERS = array( array( 'get_mysql_convert_using_bounds' ), array( 'translate_mysql_dual_table_reference_to_postgresql' ), array( 'get_mysql_index_hint_bounds' ), array( 'get_mysql_function_call_bounds', array( 'field' ) ), array( 'get_mysql_typed_cast_or_convert_bounds', array( self::MYSQL_TYPED_CAST_OR_CONVERT_REWRITE_TYPES ) ), array( 'translate_mysql_regexp_operator_to_postgresql' ), array( 'translate_mysql_group_concat_function_to_postgresql' ), array( 'get_mysql_function_call_bounds', array( 'rand' ) ), array( 'translate_mysql_session_user_function_to_postgresql' ), array( 'translate_mysql_nonparenthesized_timestamp_function_to_postgresql' ), array( 'translate_mysql_common_function_to_postgresql' ), array( 'get_mysql_date_arithmetic_function_bounds' ), array( 'get_mysql_week_function_bounds' ), array( 'get_mysql_weekday_index_function_bounds' ), array( 'get_mysql_date_format_call_bounds' ), array( 'get_mysql_limit_offset_count_bounds' ), array( 'get_mysql_extract_function_bounds' ) ); + + /** + * Practical SQL substring length cap for effectively unbounded GROUP_CONCAT. + */ + private const MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT = 2147483647; + + /** + * Prefix for MySQL AUTO_INCREMENT type metadata stored on PostgreSQL identity sequences. + */ + private const MYSQL_IDENTITY_SEQUENCE_COMMENT_TYPE_PREFIX = '__wp_mysql_auto_increment_type:'; + + /** + * Prefix for MySQL CHECK expression metadata stored on PostgreSQL constraints. + */ + private const MYSQL_CHECK_CONSTRAINT_COMMENT_CLAUSE_PREFIX = '__wp_mysql_check_clause:'; + + /** + * Prefix for MySQL CHECK enforcement metadata stored on PostgreSQL constraints. + */ + private const MYSQL_CHECK_CONSTRAINT_COMMENT_ENFORCED_PREFIX = '__wp_mysql_check_enforced:'; + + /** + * Prefix for MySQL table default collation metadata stored on PostgreSQL table comments. + */ + private const MYSQL_TABLE_COMMENT_COLLATION_PREFIX = '__wp_mysql_table_collation:'; + + /** + * Prefix for MySQL generated DEFAULT metadata stored on PostgreSQL column comments. + */ + private const MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX = '__wp_mysql_column_default:'; + + /** + * Prefix for MySQL column type metadata stored on PostgreSQL column comments. + */ + private const MYSQL_COLUMN_COMMENT_TYPE_PREFIX = WP_PostgreSQL_Create_Table_Translator::MYSQL_COLUMN_COMMENT_TYPE_PREFIX; + + /** + * Prefix for MySQL column charset metadata stored on PostgreSQL column comments. + */ + private const MYSQL_COLUMN_COMMENT_CHARSET_PREFIX = '__wp_mysql_column_charset:'; + + /** + * Prefix for MySQL column collation metadata stored on PostgreSQL column comments. + */ + private const MYSQL_COLUMN_COMMENT_COLLATION_PREFIX = '__wp_mysql_column_collation:'; + + private const POSTGRESQL_CATALOG_COLUMN_COMMENT_MARKER_PREFIXES = array( + self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX, + self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX, + self::MYSQL_COLUMN_COMMENT_CHARSET_PREFIX, + self::MYSQL_COLUMN_COMMENT_COLLATION_PREFIX, + ); + private const POSTGRESQL_CATALOG_BASE64_MARKER_PAYLOAD_PATTERN = '/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/'; + private const POSTGRESQL_CATALOG_BASE64_MARKER_PAYLOAD_SQL_PATTERN = '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'; + private const POSTGRESQL_CATALOG_USER_COMMENT_ESCAPE_PREFIX = 'WP_MYSQL_COMMENT_ESCAPE:'; + + /** + * Prefix for MySQL index type metadata stored on PostgreSQL index comments. + */ + private const MYSQL_INDEX_COMMENT_TYPE_PREFIX = '__wp_mysql_index_type:'; + + /** + * Prefix for MySQL index prefix lengths stored in PostgreSQL index comments. + */ + private const MYSQL_INDEX_COMMENT_SUB_PART_PREFIX = '__wp_mysql_index_sub_part:'; + + private const POSTGRESQL_CATALOG_COMMENT_MARKER_PREFIXES = array( + self::MYSQL_IDENTITY_SEQUENCE_COMMENT_TYPE_PREFIX, + self::MYSQL_CHECK_CONSTRAINT_COMMENT_CLAUSE_PREFIX, + self::MYSQL_CHECK_CONSTRAINT_COMMENT_ENFORCED_PREFIX, + self::MYSQL_TABLE_COMMENT_COLLATION_PREFIX, + self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX, + self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX, + self::MYSQL_COLUMN_COMMENT_CHARSET_PREFIX, + self::MYSQL_COLUMN_COMMENT_COLLATION_PREFIX, + self::MYSQL_INDEX_COMMENT_TYPE_PREFIX, + self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX, + ); + + private const MYSQL_SPATIAL_COLUMN_TYPES = array( 'geometry', 'point', 'linestring', 'polygon', 'multipoint', 'multilinestring', 'multipolygon', 'geomcollection', 'geometrycollection' ); + + private const MYSQL_CASE_INSENSITIVE_WORDPRESS_TEXT_COLUMNS = array( 'posts post_content post_excerpt post_title ', 'terms name slug ', 'term_taxonomy description taxonomy ', 'postmeta meta_value ', 'users display_name user_email user_login user_nicename user_url ' ); + + private const MYSQL_TEMPORAL_COMPARISON_COMMON_FUNCTION_NAMES = array( 'curdate', 'date', 'from_unixtime', 'localtime', 'localtimestamp', 'now', 'timestampadd', 'utc_date', 'utc_timestamp' ); + private const MYSQL_TEMPORAL_COMPARISON_NONPARENTHESIZED_FUNCTION_NAMES = array( 'current_date', 'current_timestamp', 'localtime', 'localtimestamp' ); + private const MYSQL_TEMPORAL_COLUMN_BASE_TYPES = array( 'date', 'datetime', 'timestamp' ); + private const POSTGRESQL_MYSQL_DATE_TIME_TEXT_PATTERN = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + + private const MYSQL_IMPLICIT_DML_EMPTY_STRING_DEFAULT_BASE_TYPES = array( 'char', 'varchar', 'binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set' ); + private const MYSQL_IMPLICIT_DML_ZERO_DEFAULT_BASE_TYPES = array( 'bit', 'tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real' ); + + private const MYSQL_TEXT_DOMAIN_TYPES = array( + '__wp_mysql_date' => 'date', + '__wp_mysql_datetime' => 'datetime', + '__wp_mysql_datetime_0' => 'datetime(0)', + '__wp_mysql_datetime_1' => 'datetime(1)', + '__wp_mysql_datetime_2' => 'datetime(2)', + '__wp_mysql_datetime_3' => 'datetime(3)', + '__wp_mysql_datetime_4' => 'datetime(4)', + '__wp_mysql_datetime_5' => 'datetime(5)', + '__wp_mysql_datetime_6' => 'datetime(6)', + '__wp_mysql_geomcollection' => 'geomcollection', + '__wp_mysql_geometry' => 'geometry', + '__wp_mysql_geometrycollection' => 'geometrycollection', + '__wp_mysql_json' => 'json', + '__wp_mysql_linestring' => 'linestring', + '__wp_mysql_longtext' => 'longtext', + '__wp_mysql_mediumtext' => 'mediumtext', + '__wp_mysql_multilinestring' => 'multilinestring', + '__wp_mysql_multipoint' => 'multipoint', + '__wp_mysql_multipolygon' => 'multipolygon', + '__wp_mysql_point' => 'point', + '__wp_mysql_polygon' => 'polygon', + '__wp_mysql_time' => 'time', + '__wp_mysql_time_0' => 'time(0)', + '__wp_mysql_time_1' => 'time(1)', + '__wp_mysql_time_2' => 'time(2)', + '__wp_mysql_time_3' => 'time(3)', + '__wp_mysql_time_4' => 'time(4)', + '__wp_mysql_time_5' => 'time(5)', + '__wp_mysql_time_6' => 'time(6)', + '__wp_mysql_timestamp' => 'timestamp', + '__wp_mysql_timestamp_0' => 'timestamp(0)', + '__wp_mysql_timestamp_1' => 'timestamp(1)', + '__wp_mysql_timestamp_2' => 'timestamp(2)', + '__wp_mysql_timestamp_3' => 'timestamp(3)', + '__wp_mysql_timestamp_4' => 'timestamp(4)', + '__wp_mysql_timestamp_5' => 'timestamp(5)', + '__wp_mysql_timestamp_6' => 'timestamp(6)', + '__wp_mysql_tinytext' => 'tinytext', + '__wp_mysql_year' => 'year', + ); + + private const MYSQL_BINARY_DOMAIN_TYPES = array( + '__wp_mysql_blob' => 'blob', + '__wp_mysql_longblob' => 'longblob', + '__wp_mysql_mediumblob' => 'mediumblob', + '__wp_mysql_tinyblob' => 'tinyblob', + ); + + private const MYSQL_INTEGER_DOMAIN_BASE_TYPES = array( + 'bigint' => 'bigint', + 'bit' => 'integer', + 'bool' => 'integer', + 'boolean' => 'integer', + 'int' => 'integer', + 'int1' => 'integer', + 'int2' => 'integer', + 'int3' => 'integer', + 'int4' => 'integer', + 'int8' => 'bigint', + 'mediumint' => 'integer', + 'smallint' => 'integer', + 'tinyint' => 'integer', + ); + + /** + * PostgreSQL server version string. + * + * @var string + */ + public $client_info; + + /** + * MySQL server version emulated by the driver. + * + * @var int + */ + private $mysql_version; + + /** + * PostgreSQL connection. + * + * @var WP_PostgreSQL_Connection + */ + private $connection; + + /** + * Configured main MySQL-facing database name. + * + * @var string + */ + private $main_db_name; + + /** + * Current MySQL-facing database name. + * + * @var string + */ + private $db_name; + + /** + * Result of the last query. + * + * @var mixed + */ + private $last_result; + + /** + * Column metadata for the last result set. + * + * @var array + */ + private $last_column_meta = array(); + + /** + * Number of exposed columns for the last result set. + * + * This is tracked separately so callers can ask for the column count without + * forcing PDO metadata normalization for common WordPress result fetches. + * + * @var int + */ + private $last_column_count = 0; + + /** + * Statement whose column metadata can be normalized lazily. + * + * @var PDOStatement|null + */ + private $last_column_meta_statement = null; + + /** + * Lazy metadata column names hidden from MySQL-facing callers. + * + * @var array + */ + private $last_column_meta_excluded_names = array(); + + /** + * Incoming MySQL-dialect query for the last request. + * + * @var string|null + */ + private $last_mysql_query; + + /** + * PostgreSQL queries executed for the last request. + * + * @var array + */ + private $last_postgresql_queries = array(); + + /** + * Resolved backend schema names for MySQL table introspection. + * + * @var array + */ + private $mysql_table_schema_introspection_cache = array(); + + /** + * Whether the active PostgreSQL session has temporary tables. + * + * @var bool|null + */ + private $mysql_has_active_temporary_tables = null; + + /** + * 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 MySQL column metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_column_metadata_introspection_cache = array(); + + /** + * Cached SHOW CREATE TABLE metadata keyed by backend schema and table. + * + * @var array + */ + private $mysql_show_create_table_metadata_introspection_cache = array(); + + /** + * Cached DML identity sequence-repair eligibility keyed by backend schema and table. + * + * @var array + */ + private $mysql_dml_identity_repair_eligibility_cache = array(); + + /** + * Cached MySQL unique-index metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_unique_index_metadata_introspection_cache = array(); + + /** + * Cached exact MySQL SELECT translations keyed by query hash. + * + * @var array + */ + private $mysql_select_translation_cache = array(); + + /** + * Most recently used exact MySQL SELECT translation. + * + * @var array{db_name: string, query: string, sql: string, sql_modes: array, translated: bool}|null + */ + private $mysql_select_translation_last_cache = null; + + /** + * Cached WordPress metadata priming SELECT templates keyed by query shape. + * + * @var array + */ + private $mysql_meta_priming_select_template_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; + + /** + * SQL mode string for the most recently tokenized MySQL query. + * + * @var string|null + */ + private $mysql_token_cache_sql_mode = null; + + /** + * Token stream for the most recently tokenized MySQL query. + * + * @var WP_MySQL_Token[] + */ + private $mysql_token_cache_tokens = array(); + + /** + * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. + * + * @var int + */ + private $last_found_rows = 0; + + /** + * MySQL-compatible insert ID for the last successful insert-like query. + * + * @var int|string + */ + private $last_insert_id = 0; + + /** + * LAST_INSERT_ID(expr) value staged while translating a standalone SELECT. + * + * @var int|null + */ + private $mysql_last_insert_id_assignment_value = null; + + /** + * Whether LAST_INSERT_ID(expr) translation is enabled for the current SELECT. + * + * @var bool + */ + private $mysql_last_insert_id_assignment_translation_enabled = false; + + /** + * MySQL-compatible ROW_COUNT() value preserved while per-query state resets. + * + * @var int + */ + private $last_row_count = 0; + + /** + * MySQL-compatible session SQL mode state. + * + * @var string[] + */ + private $active_sql_modes = self::DEFAULT_MYSQL_SQL_MODES; + + /** + * MySQL-compatible session character set state. + * + * @var string + */ + private $charset = self::DEFAULT_MYSQL_CHARSET; + + /** + * MySQL-compatible session collation state. + * + * @var string + */ + private $collation = self::DEFAULT_MYSQL_COLLATION; + + /** + * MySQL-compatible session variable overrides. + * + * @var array + */ + private $mysql_session_variable_values = array(); + + /** + * MySQL-compatible global variable overrides. + * + * @var array + */ + private $mysql_global_variable_values = array(); + + /** + * MySQL-compatible user variables. + * + * @var array + */ + private $mysql_user_variables = array(); + + /** + * 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 = 'PostgreSQL'; + + try { + $version = $connection->get_pdo()->getAttribute( PDO::ATTR_SERVER_VERSION ); + if ( false !== $version && null !== $version ) { + $this->client_info = (string) $version; + } + } catch ( Throwable $e ) { + $this->client_info = 'PostgreSQL'; + } + + $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Get the PostgreSQL connection instance. + * + * @return WP_PostgreSQL_Connection + */ + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + /** + * Get the PostgreSQL server version. + * + * @return string + */ + public function get_postgresql_version(): string { + return $this->client_info; + } + + /** + * Get the last executed MySQL query. + * + * @return string|null + */ + public function get_last_mysql_query(): ?string { + return $this->last_mysql_query; + } + + /** + * Get backend queries executed for the last MySQL query. + * + * @return array + */ + public function get_last_postgresql_queries(): array { + return $this->last_postgresql_queries; + } + + /** + * Get the auto-increment value generated for the last query. + * + * @return int|string + */ + public function get_insert_id() { + return is_numeric( $this->last_insert_id ) ? (int) $this->last_insert_id : $this->last_insert_id; + } + + /** + * Set the emulated MySQL session SQL mode. + * + * @param string $sql_mode Comma-separated SQL mode string. + */ + public function set_sql_mode( string $sql_mode ): void { + $this->active_sql_modes = $this->normalize_mysql_sql_modes( $sql_mode ); + unset( $this->mysql_session_variable_values['sql_mode'] ); + $this->mysql_token_cache_query = null; + $this->mysql_token_cache_sql_mode = null; + $this->mysql_token_cache_tokens = array(); + $this->mysql_select_translation_cache = array(); + $this->mysql_select_translation_last_cache = null; + $this->mysql_meta_priming_select_template_cache = array(); + $this->mysql_sql_calc_found_rows_count_query_cache = array(); + } + + /** + * Get the emulated MySQL session SQL mode. + * + * @return string Comma-separated SQL mode string. + */ + public function get_sql_mode(): string { + return implode( ',', $this->active_sql_modes ); + } + + /** + * Check if a specific SQL mode is active. + * + * @param string $mode SQL mode name. + * @return bool Whether the mode is active. + */ + public function is_sql_mode_active( string $mode ): bool { + return in_array( strtoupper( $mode ), $this->active_sql_modes, true ); + } + private function normalize_mysql_sql_modes( string $sql_mode ): array { + $sql_mode = trim( $sql_mode, "'\"` \t\n\r\0\x0B" ); + if ( '' === $sql_mode || '0' === $sql_mode ) { + return array(); + } + + if ( 'DEFAULT' === strtoupper( $sql_mode ) ) { + return self::DEFAULT_MYSQL_SQL_MODES; + } + + $normalized = array(); + foreach ( explode( ',', $sql_mode ) as $mode ) { + $mode = strtoupper( trim( $mode, "'\"` \t\n\r\0\x0B" ) ); + if ( '' === $mode ) { + continue; + } + + $modes = self::MYSQL_SQL_MODE_COMPOSITES[ $mode ] ?? array( $mode ); + foreach ( $modes as $expanded_mode ) { + if ( ! in_array( $expanded_mode, $normalized, true ) ) { + $normalized[] = $expanded_mode; + } + } + } + return $normalized; + } + + /** + * Set the emulated MySQL session charset/collation. + * + * @param string $charset MySQL charset. + * @param string|null $collation Optional MySQL collation. + */ + public function set_charset( string $charset, ?string $collation = null ): void { + if ( 'default' === $this->normalize_mysql_charset_name( $charset ) ) { + $this->charset = self::DEFAULT_MYSQL_CHARSET; + $this->collation = self::DEFAULT_MYSQL_COLLATION; + $this->sync_mysql_charset_session_variables(); + return; + } + + $this->charset = $this->normalize_mysql_charset_name( $charset ); + $this->collation = null === $collation || '' === $collation + ? ( WP_PostgreSQL_Create_Table_Translator::CHARSET_DEFAULT_COLLATION_MAP[ $this->charset ] ?? $this->charset . '_general_ci' ) + : strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $this->collation, 'utf8mb3_' ) ) { + $this->collation = 'utf8_' . substr( $this->collation, strlen( 'utf8mb3_' ) ); + } + $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 MySQL-facing column charset metadata rows for a table. + * + * This is a narrow internal metadata API for the WordPress drop-in. It + * returns the catalog shape consumed by the drop-in instead of public + * SHOW FULL COLUMNS rows. + * + * @param string $table_name MySQL table name. + * @param string|null $schema_name Optional MySQL schema. Null uses the current read schema. + * @return array|false Column metadata rows, or false when unavailable. + */ + public function get_mysql_column_charset_metadata_for_table( string $table_name, ?string $schema_name = null ) { + $table_name = trim( $table_name, "`\" \t\n\r\0\x0B" ); + if ( '' === $table_name ) { + return false; + } + + $schema_name = null === $schema_name ? null : trim( $schema_name, "`\" \t\n\r\0\x0B" ); + if ( '' === $schema_name ) { + $schema_name = null; + } + + try { + $backend_schema = $this->get_mysql_read_table_backend_schema( $schema_name ); + if ( 0 === strcasecmp( $backend_schema, 'information_schema' ) || $this->is_postgresql_internal_schema( $backend_schema ) ) { + return false; + } + + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $backend_schema, $table_name ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + return false; + } + + $rows = $this->get_cached_mysql_table_catalog_column_metadata_rows( $resolved_schema, $table_name ); + } catch ( Throwable $e ) { + return false; + } + + if ( empty( $rows ) ) { + return false; + } + + $metadata_rows = array(); + foreach ( $rows as $row ) { + if ( empty( $row['column_name'] ) ) { + continue; + } + + $metadata_rows[] = array( + 'column_name' => $row['column_name'], + 'column_type' => $row['column_type'] ?? '', + 'collation_name' => $row['collation_name'] ?? null, + ); + } + + return empty( $metadata_rows ) ? false : $metadata_rows; + } + + /** + * Execute a query. + * + * @param string $query Full SQL statement string. + * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Return value, depending on the query type. + */ + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->last_row_count = is_array( $this->last_result ) + ? -1 + : ( is_numeric( $this->last_result ) ? (int) $this->last_result : 0 ); + $this->reset_query_state(); + $this->last_result = -1; + $this->last_mysql_query = $query; + + $mysql_query_context = null; + $translated_for_postgresql = false; + $top_level_query_result = $this->apply_mysql_top_level_query_dispatch_rules( $query, $translated_for_postgresql, $fetch_mode, $fetch_mode_args, $mysql_query_context ); + if ( null !== $top_level_query_result ) { + return $top_level_query_result; + } + + list( $dml_identity_repair_query, $last_insert_id_after_success, $replace_return_value ) = array( null, null, null ); + $mysql_update_ignore_query = $this->is_mysql_update_ignore_query( $query, $mysql_query_context ); + + $dml_rewrite_result = $this->apply_mysql_dml_rewrite_rules( + $query, + $translated_for_postgresql, + $dml_identity_repair_query, + $replace_return_value, + $mysql_update_ignore_query, + $mysql_query_context + ); + if ( null !== $dml_rewrite_result ) { + return $dml_rewrite_result; + } + + $cached_select_translation = ( + ! $translated_for_postgresql + && false === stripos( $query, 'SQL_CALC_FOUND_ROWS' ) + && 1 === preg_match( '/\A\s*SELECT\b/i', $query ) + ) ? $this->get_last_mysql_select_query_translation( $query ) : null; + if ( null !== $cached_select_translation ) { + $query = $cached_select_translation['sql']; + $translated_for_postgresql = $cached_select_translation['translated']; + } + + $is_sql_calc_found_rows_query = $translated_for_postgresql ? false : null !== $this->get_sql_calc_found_rows_select_parts( $query, true, true, $mysql_query_context ); + $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 && in_array( (int) $fetch_mode, array( PDO::FETCH_OBJ, PDO::FETCH_ASSOC ), true ) + ? $this->translate_sql_calc_found_rows_window_select_query( $query, $mysql_query_context ) + : null; + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + $sql_calc_found_rows_window = true; + } elseif ( + 1 === preg_match( '/\A\s*SELECT\b/i', $query ) + && ! $this->contains_uncacheable_mysql_runtime_function_query( $query, $mysql_query_context ) + ) { + $select_translation = $this->get_mysql_select_query_translation( $query, $mysql_query_context ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; + if ( array_key_exists( 'last_insert_id', $select_translation ) ) { + $last_insert_id_after_success = $select_translation['last_insert_id']; + } + } elseif ( $this->is_mysql_top_level_select_query( $query, $mysql_query_context ) ) { + $select_translation = $this->translate_mysql_select_query_for_postgresql( $query, $mysql_query_context ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; + if ( array_key_exists( 'last_insert_id', $select_translation ) ) { + $last_insert_id_after_success = $select_translation['last_insert_id']; + } + } else { + $translated_query = $this->translate_mysql_compatible_query( $query, $mysql_query_context ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + } + } + + $this->reject_unsupported_mysql_constructs( $query, $this->get_mysql_post_translation_unsupported_construct_guards() ); + + $unsupported_mysql_administration_statement = $this->get_unsupported_mysql_administration_statement_message( $query ); + if ( null !== $unsupported_mysql_administration_statement ) { + throw new InvalidArgumentException( $unsupported_mysql_administration_statement ); + } + + $this->ensure_postgresql_runtime_helpers_for_query( $query ); + $stmt = $this->connection->query( $query ); + $this->last_postgresql_queries[] = array( + 'sql' => $query, + 'params' => array(), + ); + + $affected_rows = $stmt->rowCount(); + + $column_count = $stmt->columnCount(); + if ( $column_count > 0 ) { + $this->last_column_meta = array(); + $this->last_column_count = $column_count; + $this->last_column_meta_statement = $stmt; + $this->last_column_meta_excluded_names = array(); + $this->last_result = $this->fetch_and_decode_postgresql_result_rows( + $stmt, + $fetch_mode, + $fetch_mode_args + ); + if ( $sql_calc_found_rows_window ) { + $found_rows = $this->extract_sql_calc_found_rows_window_result( $this->last_result ); + $this->remove_sql_calc_found_rows_window_column_meta(); + $this->last_found_rows = null === $found_rows && null !== $sql_calc_found_rows_query + ? $this->execute_sql_calc_found_rows_count_query( $sql_calc_found_rows_query ) + : (int) $found_rows; + } elseif ( null !== $sql_calc_found_rows_query ) { + $this->last_found_rows = $this->execute_sql_calc_found_rows_count_query( $sql_calc_found_rows_query ); + } + } else { + $this->clear_last_column_meta(); + $this->last_result = $affected_rows; + if ( null !== $replace_return_value ) { + $this->last_result = $replace_return_value; + } + } + + if ( null !== $last_insert_id_after_success ) { + $this->last_insert_id = $last_insert_id_after_success; + } + + if ( null !== $dml_identity_repair_query ) { + $this->set_last_insert_id_after_dml_success( $dml_identity_repair_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_identity_repair_query, $affected_rows ); + } + return $this->last_result; + } + private function execute_mysql_static_select_query( string $query, $fetch_mode, ...$fetch_mode_args ) { + $mysql_variable_select_query = $this->get_mysql_variable_select_query( $query ); + if ( null !== $mysql_variable_select_query ) { + return $this->set_mysql_static_show_result( + $mysql_variable_select_query['columns'], + array( $mysql_variable_select_query['row'] ), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $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; + } + $database_function_column = $tokens[1]->get_value() . '()'; + return $this->set_mysql_static_show_result( + array( $database_function_column ), + array( array( $database_function_column => $this->db_name ) ), + $fetch_mode, + ...$fetch_mode_args + ); + } + private function execute_mysql_show_query( string $query, $fetch_mode, ...$fetch_mode_args ) { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + foreach ( array( + array( 'variables', array( array( WP_MySQL_Lexer::VARIABLES_SYMBOL ) ), 'Variable_name', 'name_value', 'Unsupported SHOW VARIABLES statement.', array( 'Value' ), true ), + array( 'character_set', array( array( WP_MySQL_Lexer::CHARSET_SYMBOL ), array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::SET_SYMBOL ) ), 'Charset', 'character_set', 'Unsupported SHOW CHARACTER SET statement.', array( 'Maxlen' ) ), + array( 'collation', array( array( WP_MySQL_Lexer::COLLATION_SYMBOL ) ), 'Collation', 'collation', 'Unsupported SHOW COLLATION statement.', array( 'Id', 'Sortlen' ) ), + array( 'databases', array( array( WP_MySQL_Lexer::DATABASES_SYMBOL ), array( WP_MySQL_Lexer::SCHEMAS_SYMBOL ) ), 'Database', 'databases', 'Unsupported SHOW DATABASES statement.' ), + array( 'create_database', 'get_show_create_database_query' ), + array( 'engines', array( array( WP_MySQL_Lexer::STORAGE_SYMBOL, WP_MySQL_Lexer::ENGINES_SYMBOL ), array( WP_MySQL_Lexer::ENGINES_SYMBOL ) ), 'Engine', 'engines', 'Unsupported SHOW ENGINES statement.' ), + array( 'plugins', array( array( WP_MySQL_Lexer::PLUGINS_SYMBOL ) ), 'Name', 'plugins', 'Unsupported SHOW PLUGINS statement.' ), + array( 'routine_status', array( array( WP_MySQL_Lexer::FUNCTION_SYMBOL, WP_MySQL_Lexer::STATUS_SYMBOL ), array( WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::STATUS_SYMBOL ) ), 'Name', 'routine_status', null, array(), false, null, true ), + array( 'events', array( array( WP_MySQL_Lexer::EVENTS_SYMBOL ) ), 'Name', 'events', 'Unsupported SHOW EVENTS statement.', array(), false, 'read' ), + array( 'grants', 'get_show_grants_query' ), + array( 'status', array( array( WP_MySQL_Lexer::STATUS_SYMBOL ) ), 'Variable_name', 'name_value', 'Unsupported SHOW STATUS statement.', array( 'Value' ), true ), + array( 'diagnostics', 'get_show_diagnostics_query', array( WP_MySQL_Lexer::WARNINGS_SYMBOL, 'warnings' ) ), + array( 'diagnostics', 'get_show_diagnostics_query', array( WP_MySQL_Lexer::ERRORS_SYMBOL, 'errors' ) ), + array( 'processlist', 'get_show_processlist_query' ), + array( 'open_tables', array( array( WP_MySQL_Lexer::OPEN_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ) ), 'Table', 'open_tables', 'Unsupported SHOW OPEN TABLES statement.', array( 'In_use', 'Name_locked' ), false, 'database' ), + array( 'triggers', array( array( WP_MySQL_Lexer::TRIGGERS_SYMBOL ) ), 'Trigger', 'triggers', 'Unsupported SHOW TRIGGERS statement.', array(), false, 'read_display' ), + ) as $dispatcher ) { + $parsed_query = is_string( $dispatcher[1] ) ? $this->{$dispatcher[1]}( $query, ...( $dispatcher[2] ?? array() ) ) : $this->get_mysql_show_descriptor_query( $tokens, $dispatcher ); + if ( null !== $parsed_query ) { + return $this->execute_mysql_show_result_descriptor( $dispatcher[0], $parsed_query, $fetch_mode, ...$fetch_mode_args ); + } + } + return null; + } + private function get_mysql_show_descriptor_query( array $tokens, array $descriptor ): ?array { + $position = 1; + $parsed_query = array(); + if ( ! empty( $descriptor[6] ) ) { + $parsed_query['scope'] = 'session'; + if ( in_array( $tokens[ $position ]->id ?? null, array( WP_MySQL_Lexer::GLOBAL_SYMBOL, WP_MySQL_Lexer::SESSION_SYMBOL ), true ) ) { + $parsed_query['scope'] = WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id ? 'global' : 'session'; + ++$position; + } + } + + $match_start = $position; + $matched_tokens = $this->match_mysql_show_descriptor_tokens( $tokens, $position, $descriptor[1] ); + if ( null === $matched_tokens ) { + return null; + } + + $position += count( $matched_tokens ); + if ( ! empty( $descriptor[8] ) ) { + $parsed_query['routine_type'] = WP_MySQL_Lexer::FUNCTION_SYMBOL === $tokens[ $match_start ]->id ? 'FUNCTION' : 'PROCEDURE'; + $parsed_query['unsupported_message'] = sprintf( 'Unsupported SHOW %s STATUS statement.', $parsed_query['routine_type'] ); + } + + $unsupported_message = $parsed_query['unsupported_message'] ?? ( $descriptor[4] ?? 'Unsupported SHOW statement.' ); + $schema_type = $descriptor[7] ?? null; + if ( null !== $schema_type ) { + if ( 'database' === $schema_type ) { + $database_name = $this->get_mysql_show_database_name( $tokens, $position, $unsupported_message ); + $parsed_query['schema'] = $this->get_mysql_show_database_backend_schema( $database_name, substr( $unsupported_message, strlen( 'Unsupported ' ), -strlen( ' statement.' ) ) ); + } else { + $schema_name = $this->get_mysql_show_optional_schema_name( $tokens, $position, $unsupported_message ); + $backend_schema = $this->get_mysql_read_table_backend_schema( $schema_name ); + if ( $this->is_postgresql_internal_schema( $backend_schema ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + $parsed_query['schema'] = 'read_display' === $schema_type ? $this->get_direct_information_schema_display_schema( $backend_schema ) : $backend_schema; + } + } + + $parsed_query['filter'] = $this->get_mysql_show_result_filter( $tokens, $position, $descriptor[2], is_string( $descriptor[3] ) ? $this->get_mysql_show_output_columns( $descriptor[3] ) : $descriptor[3], $unsupported_message, $descriptor[5] ?? array() ); + return $parsed_query; + } + private function match_mysql_show_descriptor_tokens( array $tokens, int $position, array $alternatives ): ?array { + foreach ( $alternatives as $candidate ) { + foreach ( $candidate as $offset => $token_id ) { + if ( ( $tokens[ $position + $offset ]->id ?? null ) !== $token_id ) { + continue 2; + } + } + return $candidate; + } + return null; + } + private function execute_mysql_metadata_show_query( string $query, $fetch_mode, ...$fetch_mode_args ) { + foreach ( array( array( 'get_describe_table_reference', 'execute_show_columns_query', array( 'schema', 'table' ), array( false, null, null ) ), array( 'get_show_tables_query', 'execute_show_tables_query', array( 'full', 'schema', 'database', 'like', 'where' ), array() ), array( 'get_show_table_status_query', 'execute_show_table_status_query', null, array() ), array( 'get_show_create_table_query', 'execute_show_create_table_query', null, array() ), array( 'get_show_columns_query', 'execute_show_columns_query', array( 'schema', 'table', 'full', 'like', 'where' ), array() ), array( 'get_show_index_query', 'execute_show_index_query', array( 'schema', 'table', 'where' ), array() ), array( 'get_mysql_table_administration_query', 'execute_mysql_table_administration_query', null, array() ) ) as $dispatcher ) { + $parsed_query = $this->{$dispatcher[0]}( $query ); + if ( null !== $parsed_query ) { + $args = array(); + if ( null === $dispatcher[2] ) { + $args[] = $parsed_query; + } else { + foreach ( $dispatcher[2] as $field ) { + $args[] = $parsed_query[ $field ]; + } + $args = array_merge( $args, $dispatcher[3] ); + } + return $this->{$dispatcher[1]}( ...array_merge( $args, array( $fetch_mode ), $fetch_mode_args ) ); + } + } + return null; + } + private function reject_unsupported_mysql_constructs( string $query, array $guards ): void { + foreach ( $guards as $guard ) { + $guard_args = array_slice( $guard, 2 ); + if ( $this->{$guard[0]}( $query, ...$guard_args ) ) { + throw new InvalidArgumentException( $guard[1] ); + } + } + } + private function translate_first_mysql_query( string $query, array $translator_names, ?array &$query_context = null ): ?string { + foreach ( $translator_names as $translator_name ) { + $translated_query = $this->translate_mysql_query_with_context( $translator_name, $query, $query_context ); + if ( null !== $translated_query ) { + return $translated_query; + } + } + return null; + } + private function translate_mysql_query_with_context( string $translator_name, string $query, ?array &$query_context = null ): ?string { + switch ( $translator_name ) { + case 'translate_application_select_with_direct_information_schema_nested_selects': + case 'translate_direct_information_schema_select_query': + if ( WP_MySQL_Lexer::SELECT_SYMBOL !== $this->get_mysql_query_context_first_token_id( $query, $query_context ) ) { + return null; + } + break; + } + + switch ( $translator_name ) { + case 'translate_application_select_with_direct_information_schema_nested_selects': + return $this->translate_application_select_with_direct_information_schema_nested_selects( $query, $query_context ); + case 'translate_direct_information_schema_select_query': + return $this->translate_direct_information_schema_select_query( $query, array(), $query_context ); + case 'translate_distinct_order_by_query': + return $this->translate_distinct_order_by_query( $query, true, $query_context ); + case 'translate_grouped_having_alias_query': + return $this->translate_grouped_having_alias_query( $query, $query_context ); + case 'translate_information_schema_main_database_select_query': + return $this->translate_information_schema_main_database_select_query( $query, $query_context ); + case 'translate_mysql_compatible_query': + return $this->translate_mysql_compatible_query( $query, $query_context ); + case 'translate_mysql_select_row_locking_query': + return $this->translate_mysql_select_row_locking_query( $query, $query_context ); + case 'translate_mysql_version_function_select_query': + return $this->translate_mysql_version_function_select_query( $query, $query_context ); + case 'translate_simple_mysql_select_query': + return $this->translate_simple_mysql_select_query( $query, $query_context ); + case 'translate_sql_calc_found_rows_select_query': + return $this->translate_sql_calc_found_rows_select_query( $query, true, $query_context ); + case 'translate_strict_aggregate_grouped_order_by_query': + return $this->translate_strict_aggregate_grouped_order_by_query( $query, true, $query_context ); + } + return $this->{$translator_name}( $query ); + } + private function execute_mysql_admin_statements( array $statements, bool $clear_metadata_caches ): int { + $this->execute_postgresql_statements( $statements ); + if ( $clear_metadata_caches ) { + $this->clear_mysql_metadata_caches(); + } + $this->last_result = 0; + return $this->last_result; + } + private function execute_mysql_translated_create_table_query( array $create_table_query ): int { + if ( ! empty( $create_table_query['noop'] ) ) { + return $this->execute_mysql_admin_noop_query(); + } + + $metadata_query = $create_table_query['metadata_query'] ?? null; + $metadata_tables = null === $metadata_query + ? array() + : $this->get_postgresql_catalog_mysql_schema_metadata_or_fail( $metadata_query ); + $result = $this->execute_postgresql_statements( + $this->prepend_postgresql_mysql_helper_type_statements( + $create_table_query['statements'], + $metadata_query, + null !== $metadata_query + ) + ); + $metadata_schema = ! empty( $create_table_query['temporary'] ) + ? $this->get_temporary_schema_for_metadata_table( $create_table_query['table'] ) + : $create_table_query['schema']; + + if ( null !== $metadata_query ) { + $this->sync_mysql_schema_catalog_side_effects_for_schema( + $metadata_tables, + ! empty( $create_table_query['temporary'] ) ? array( $this, 'get_temporary_schema_for_metadata_table' ) : $metadata_schema + ); + $this->seed_mysql_column_metadata_introspection_cache_for_created_tables( + $metadata_tables, + ! empty( $create_table_query['temporary'] ) ? array( $this, 'get_temporary_schema_for_metadata_table' ) : $metadata_schema, + $metadata_query + ); + $this->seed_mysql_show_create_table_metadata_introspection_cache_for_created_tables( + $metadata_tables, + ! empty( $create_table_query['temporary'] ) ? array( $this, 'get_temporary_schema_for_metadata_table' ) : $metadata_schema, + $metadata_query + ); + if ( ! empty( $create_table_query['temporary'] ) ) { + $this->mark_mysql_temporary_table_created( $create_table_query['table'] ); + } + return $result; + } + + $this->clear_mysql_metadata_caches(); + if ( '' !== ( $create_table_query['table_comment'] ?? '' ) ) { + $this->sync_postgresql_catalog_table_comment( $metadata_schema, $create_table_query['table'], $create_table_query['table_comment'] ); + } + if ( ! empty( $create_table_query['temporary'] ) ) { + $this->mark_mysql_temporary_table_created( $create_table_query['table'] ); + } + return $result; + } + private function get_unsupported_mysql_administration_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::CALL_SYMBOL === $tokens[0]->id ) { + return 'Unsupported CALL statement.'; + } + + $message = $this->get_mysql_unsupported_statement_message( 'administration', $tokens[0]->id ); + if ( null !== $message ) { + return $message; + } + + if ( WP_MySQL_Lexer::ALTER_SYMBOL === $tokens[0]->id ) { + return $this->get_mysql_unsupported_statement_message( 'alter', $tokens[1]->id ?? null ); + } + + return WP_MySQL_Lexer::RENAME_SYMBOL === $tokens[0]->id && WP_MySQL_Lexer::USER_SYMBOL === ( $tokens[1]->id ?? null ) + ? 'Unsupported RENAME USER statement.' + : null; + } + private function get_unsupported_mysql_create_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::OR_SYMBOL === ( $tokens[ $position ]->id ?? null ) && WP_MySQL_Lexer::REPLACE_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $position += 2; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_unsupported_mysql_create_table_statement_message( $tokens, $position + 1 ); + } + + $statement_token = $tokens[ $position ] ?? null; + $known_message = $this->get_mysql_unsupported_statement_message( 'create', $statement_token->id ?? null ); + if ( null !== $known_message ) { + return $known_message; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return 'Unsupported CREATE statement.'; + } + + if ( null !== $this->consume_mysql_table_administration_token_sequence( $tokens, $position, self::MYSQL_SPATIAL_REFERENCE_SYSTEM_STATEMENT_TOKENS ) ) { + return 'Unsupported CREATE SPATIAL REFERENCE SYSTEM statement.'; + } + + return 'Unsupported CREATE statement.'; + } + private function get_unsupported_mysql_create_table_statement_message( array $tokens, int $position ): ?string { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $table_reference ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( + $this->is_at_mysql_query_end( $tokens, $position ) + || ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) + ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $has_as = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $has_as = true; + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || ( $has_as && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $definition_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null !== $definition_end && $this->is_at_mysql_query_end( $tokens, $definition_end ) ) { + return null; + } + return 'Unsupported CREATE TABLE statement.'; + } + private function contains_unsupported_mysql_create_table_column_attribute_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( + null === $statement_end + || ! $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, 0, $statement_end ) + ) { + return false; + } + + ++$position; + $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + + $target_position = $position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $target_position, true ); + if ( + null === $table_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $target_position, $statement_end ) + ) { + return false; + } + + try { + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $translator->translate_schema( $query ); + } catch ( InvalidArgumentException $e ) { + return 'Unsupported CREATE TABLE column attribute.' === $e->getMessage(); + } + return false; + } + private function contains_uncacheable_mysql_runtime_function_query( string $query, ?array &$query_context = null ): bool { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return false; + } + + for ( $i = 1; $i < $statement_end; $i++ ) { + if ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $i ]->id + || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $i ]->id + ) { + return true; + } + + if ( null !== $this->get_mysql_group_concat_function_bounds( $tokens, $i, $statement_end ) ) { + return true; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $statement_end ); + if ( null !== $bounds && in_array( $bounds['function'], array( 'found_rows', 'last_insert_id', 'row_count' ), true ) ) { + return true; + } + } + return false; + } + private function get_last_mysql_select_query_translation( string $query ): ?array { + if ( + null === $this->mysql_select_translation_last_cache + || $this->mysql_select_translation_last_cache['query'] !== $query + || $this->mysql_select_translation_last_cache['db_name'] !== $this->db_name + || $this->mysql_select_translation_last_cache['sql_modes'] !== $this->active_sql_modes + ) { + return null; + } + + return array( + 'sql' => $this->mysql_select_translation_last_cache['sql'], + 'translated' => $this->mysql_select_translation_last_cache['translated'], + ); + } + private function get_mysql_select_query_translation( string $query, ?array &$query_context = null ): array { + $last_translation = $this->get_last_mysql_select_query_translation( $query ); + if ( null !== $last_translation ) { + return $last_translation; + } + + $meta_priming_translation = $this->get_mysql_usermeta_priming_select_template_translation( $query, $query_context ); + if ( null !== $meta_priming_translation ) { + $this->mysql_select_translation_last_cache = array( + 'db_name' => $this->db_name, + 'query' => $query, + 'sql' => $meta_priming_translation['sql'], + 'sql_modes' => $this->active_sql_modes, + 'translated' => $meta_priming_translation['translated'], + ); + return $meta_priming_translation; + } + + $cache_key = sha1( $this->db_name . "\0" . implode( ',', $this->active_sql_modes ) . "\0" . $query ); + if ( + isset( $this->mysql_select_translation_cache[ $cache_key ] ) + && $this->mysql_select_translation_cache[ $cache_key ]['query'] === $query + ) { + $this->mysql_select_translation_last_cache = array( + 'db_name' => $this->db_name, + 'query' => $query, + 'sql' => $this->mysql_select_translation_cache[ $cache_key ]['sql'], + 'sql_modes' => $this->active_sql_modes, + 'translated' => $this->mysql_select_translation_cache[ $cache_key ]['translated'], + ); + return array( + 'sql' => $this->mysql_select_translation_cache[ $cache_key ]['sql'], + 'translated' => $this->mysql_select_translation_cache[ $cache_key ]['translated'], + ); + } + + $translation = $this->translate_mysql_select_query_for_postgresql( $query, $query_context ); + $this->mysql_select_translation_cache[ $cache_key ] = array( + 'query' => $query, + 'sql' => $translation['sql'], + 'translated' => $translation['translated'], + ); + $this->mysql_select_translation_last_cache = array( + 'db_name' => $this->db_name, + 'query' => $query, + 'sql' => $translation['sql'], + 'sql_modes' => $this->active_sql_modes, + 'translated' => $translation['translated'], + ); + $this->limit_mysql_query_translation_cache( $this->mysql_select_translation_cache ); + return $translation; + } + private function get_mysql_usermeta_priming_select_template_translation( string $query, ?array &$query_context = null ): ?array { + $shape = $this->get_mysql_usermeta_priming_select_template_shape( $query, $query_context ); + if ( null === $shape ) { + return null; + } + + $cache_key = $this->get_mysql_usermeta_priming_select_template_cache_key( $shape ); + if ( ! isset( $this->mysql_meta_priming_select_template_cache[ $cache_key ] ) ) { + $this->mysql_meta_priming_select_template_cache[ $cache_key ] = $this->get_mysql_usermeta_priming_select_translation_template( $shape ); + $this->limit_mysql_query_translation_cache( $this->mysql_meta_priming_select_template_cache ); + } + + $template = $this->mysql_meta_priming_select_template_cache[ $cache_key ]; + return array( + 'sql' => $template['prefix_sql'] . implode( ', ', $this->get_mysql_usermeta_priming_select_slot_sql( $shape ) ) . $template['suffix_sql'], + 'translated' => true, + ); + } + private function get_mysql_usermeta_priming_select_template_shape( string $query, ?array &$query_context = null ): ?array { + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + return null; + } + + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + 1, + $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::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_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, + WP_MySQL_Lexer::WITH_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || ! $this->is_mysql_usermeta_priming_select_projection( $tokens, 1, $from_position ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::ORDER_SYMBOL, $from_position + 1, $statement_end ); + if ( null === $where_position || null === $order_position || $where_position > $order_position ) { + return null; + } + + $table_reference_start = $from_position + 1; + $table_name_end = $table_reference_start; + $table_name_for_sql = $this->parse_mysql_main_database_table_name( $tokens, $table_name_end ); + $position = $table_reference_start; + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $where_position ); + if ( + null === $table_name_for_sql + || null === $table_reference + || null !== $table_reference['alias'] + || $table_name_for_sql !== $table_reference['table'] + || $position !== $where_position + || ! $this->is_mysql_wordpress_table_name( $table_reference['table'], 'usermeta' ) + ) { + return null; + } + + $where = $this->get_mysql_usermeta_priming_select_where_shape( $tokens, $where_position + 1, $order_position ); + if ( null === $where ) { + return null; + } + + $order_reference = $this->get_mysql_usermeta_priming_select_order_reference( $tokens, $order_position, $statement_end ); + if ( null === $order_reference ) { + return null; + } + + return array( + 'tokens' => $tokens, + 'from_position' => $from_position, + 'order_reference' => $order_reference, + 'owner_column' => 'user_id', + 'slot_count' => count( $where['slots'] ), + 'slots' => $where['slots'], + 'order_column' => 'umeta_id', + 'static_signature' => $this->get_mysql_usermeta_priming_select_static_signature( + $tokens, + array( + array( 1, $from_position ), + array( $table_reference_start, $table_name_end ), + array( $where['reference']['start'], $where['reference']['end'] ), + array( $order_reference['start'], $order_reference['end'] ), + ) + ), + 'table_name' => $table_reference['table'], + 'table_name_end' => $table_name_end, + 'table_reference_start' => $table_reference_start, + 'table_schema' => $this->get_mysql_unqualified_dml_table_backend_schema( $table_reference['table'] ), + 'where_reference' => $where['reference'], + ); + } + private function is_mysql_usermeta_priming_select_projection( array $tokens, int $start, int $end ): bool { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || 3 !== count( $ranges ) ) { + return false; + } + + foreach ( array( 'user_id', 'meta_key', 'meta_value' ) as $index => $column ) { + $reference = $this->parse_mysql_column_reference( $tokens, $ranges[ $index ]['start'], $ranges[ $index ]['end'] ); + if ( + null === $reference + || null !== $reference['qualifier'] + || $reference['end'] !== $ranges[ $index ]['end'] + || 0 !== strcasecmp( $reference['column'], $column ) + ) { + return false; + } + } + return true; + } + private function get_mysql_usermeta_priming_select_where_shape( array $tokens, int $start, int $end ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || null !== $reference['qualifier'] + || 0 !== strcasecmp( $reference['column'], 'user_id' ) + || ! 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 null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $end ); + if ( $after_close !== $end ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $end - 1 ); + if ( null === $items || empty( $items ) ) { + return null; + } + + $slots = array(); + foreach ( $items as $item ) { + $form = $this->get_mysql_usermeta_priming_select_slot_form( $tokens, $item ); + if ( null === $form ) { + return null; + } + + $slots[] = array( + 'start' => $item['start'], + 'end' => $item['end'], + 'form' => $form, + ); + } + return array( + 'reference' => $reference, + 'slots' => $slots, + ); + } + private function get_mysql_usermeta_priming_select_order_reference( array $tokens, int $order_position, int $statement_end ): ?array { + if ( + $order_position + 4 !== $statement_end + || ! isset( $tokens[ $order_position + 1 ], $tokens[ $order_position + 3 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || WP_MySQL_Lexer::ASC_SYMBOL !== $tokens[ $order_position + 3 ]->id + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $order_position + 2, $order_position + 3 ); + return null !== $reference + && null === $reference['qualifier'] + && $reference['end'] === $order_position + 3 + && 0 === strcasecmp( $reference['column'], 'umeta_id' ) + ? $reference + : null; + } + private function get_mysql_usermeta_priming_select_slot_form( array $tokens, array $item ): ?string { + if ( $item['start'] + 1 !== $item['end'] || ! isset( $tokens[ $item['start'] ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::PARAM_MARKER === $tokens[ $item['start'] ]->id ) { + return 'placeholder'; + } + + return $this->is_mysql_unsigned_integer_token( $tokens[ $item['start'] ] ) + ? 'literal:' . (string) $tokens[ $item['start'] ]->id + : null; + } + private function get_mysql_usermeta_priming_select_static_signature( array $tokens, array $ranges ): string { + $parts = array(); + foreach ( $ranges as $range ) { + for ( $i = $range[0]; $i < $range[1]; $i++ ) { + $parts[] = $tokens[ $i ]->id . ':' . $tokens[ $i ]->get_bytes(); + } + $parts[] = ';'; + } + return implode( '|', $parts ); + } + private function get_mysql_usermeta_priming_select_template_cache_key( array $shape ): string { + return sha1( + implode( + "\0", + array( + $this->main_db_name, + $this->db_name, + implode( ',', $this->active_sql_modes ), + $shape['table_schema'], + $shape['table_name'], + 'usermeta', + $shape['owner_column'], + $shape['order_column'], + (string) $shape['slot_count'], + implode( ',', array_column( $shape['slots'], 'form' ) ), + $shape['static_signature'], + ) + ) + ); + } + private function get_mysql_usermeta_priming_select_translation_template( array $shape ): array { + $tokens = $shape['tokens']; + return array( + 'prefix_sql' => sprintf( + 'SELECT %s FROM %s WHERE %s IN (', + $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $shape['from_position'] ), + $this->get_mysql_main_database_table_reference_sql( + $tokens, + $shape['table_reference_start'], + $shape['table_name_end'] + ), + $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $shape['where_reference']['start'], + $shape['where_reference']['end'] + ) + ), + 'suffix_sql' => sprintf( + ') ORDER BY %s ASC', + $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $shape['order_reference']['start'], + $shape['order_reference']['end'] + ) + ), + ); + } + private function get_mysql_usermeta_priming_select_slot_sql( array $shape ): array { + $slot_sql = array(); + foreach ( $shape['slots'] as $slot ) { + $slot_sql[] = $this->translate_mysql_token_sequence_to_postgresql( $shape['tokens'], $slot['start'], $slot['end'] ); + } + return $slot_sql; + } + private function is_mysql_top_level_select_query( string $query, ?array &$query_context = null ): bool { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + return isset( $tokens[0] ) + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id + && null !== $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + } + private function translate_mysql_select_query_for_postgresql( string $query, ?array &$query_context = null ): array { + $classification = $this->get_mysql_select_translation_classification( $query, $query_context ); + foreach ( self::MYSQL_SELECT_TRANSLATOR_PIPELINE as $translator ) { + $guard = $translator['guard'] ?? null; + if ( null !== $guard && empty( $classification[ $guard ] ) ) { + continue; + } + + if ( ! empty( $translator['reject_information_schema'] ) ) { + $this->reject_unsupported_information_schema_select_query( $query, $query_context ); + continue; + } + + $translation = ! empty( $translator['last_insert_id'] ) + ? $this->translate_mysql_last_insert_id_assignment_select_query( $query, $query_context ) + : $this->translate_first_mysql_query( $query, $translator['translators'], $query_context ); + if ( null === $translation ) { + continue; + } + + if ( ! empty( $translator['last_insert_id'] ) ) { + return array( + 'sql' => $translation['sql'], + 'translated' => true, + 'last_insert_id' => $translation['last_insert_id'], + ); + } + + return array( + 'sql' => $translation, + 'translated' => true, + ); + } + return array( + 'sql' => $query, + 'translated' => false, + ); + } + private function get_mysql_select_translation_classification( string $query, ?array &$query_context = null ): array { + $classification = array( + 'has_distinct' => false, + 'has_row_locking_clause' => false, + 'has_sql_calc_found_rows' => false, + 'may_assign_last_insert_id' => false, + 'may_need_aggregate_order_rewrite' => false, + 'may_read_version_function' => false, + 'may_target_main_database_from_information_schema' => false, + 'may_use_simple_select' => false, + 'requires_information_schema_guard' => false, + ); + + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return $classification; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + $top_level_token_ids = $this->get_mysql_query_context_top_level_token_index( $query_context, 1, $statement_end ); + $from_position = $top_level_token_ids[ WP_MySQL_Lexer::FROM_SYMBOL ][0] ?? null; + + $classification['has_distinct'] = ! empty( $top_level_token_ids[ WP_MySQL_Lexer::DISTINCT_SYMBOL ] ); + $classification['has_row_locking_clause'] = null !== $this->find_mysql_select_row_locking_clause_start( $tokens, 1, $statement_end ); + $classification['has_sql_calc_found_rows'] = ! empty( $top_level_token_ids[ WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL ] ); + $classification['may_assign_last_insert_id'] = $this->mysql_select_range_contains_common_function( $tokens, 1, $statement_end, 'last_insert_id' ); + $classification['may_need_aggregate_order_rewrite'] = ! empty( $top_level_token_ids[ WP_MySQL_Lexer::ORDER_SYMBOL ] ) || ! empty( $top_level_token_ids[ WP_MySQL_Lexer::HAVING_SYMBOL ] ); + $classification['may_read_version_function'] = $this->mysql_select_range_contains_common_function( $tokens, 1, $statement_end, 'version' ); + $classification['may_target_main_database_from_information_schema'] = 0 === strcasecmp( $this->db_name, 'information_schema' ); + $classification['may_use_simple_select'] = null !== $from_position && ! $this->contains_top_level_mysql_query_context_token( $query_context, 1, $statement_end, self::MYSQL_SIMPLE_SELECT_UNSUPPORTED_TOKENS ); + $classification['requires_information_schema_guard'] = $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, 0, $statement_end ); + return $classification; + } + private function mysql_select_range_contains_common_function( array $tokens, int $start, int $end, string $function_name ): bool { + for ( $i = $start; $i < $end; $i++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $end ); + if ( null !== $bounds && $function_name === $bounds['function'] ) { + return true; + } + } + return false; + } + private function reject_unsupported_information_schema_select_query( string $query, ?array &$query_context = null ): void { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( isset( $tokens[0] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( + null !== $statement_end + && ( + $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) + || ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + && ! $this->information_schema_select_query_targets_main_database_explicitly( $query, $tokens, $statement_end ) + && $this->information_schema_select_has_table_reference( $tokens ) + ) + ) + ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + } + } + 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 ] ); + } + } + private function fetch_and_decode_postgresql_result_rows( PDOStatement $stmt, $fetch_mode, array $fetch_mode_args ): array { + if ( ! $this->can_fetch_postgresql_result_rows_incrementally( $fetch_mode, $fetch_mode_args ) ) { + return $this->decode_postgresql_text_for_mysql_in_result( + $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) + ); + } + + $rows = array(); + while ( false !== ( $row = $stmt->fetch( $fetch_mode ) ) ) { + $rows[] = $this->decode_postgresql_text_for_mysql_in_result( $row ); + } + return $rows; + } + private function can_fetch_postgresql_result_rows_incrementally( $fetch_mode, array $fetch_mode_args ): bool { + if ( ! empty( $fetch_mode_args ) ) { + return false; + } + + $fetch_mode = (int) $fetch_mode; + $base_fetch_style = $fetch_mode & self::PDO_FETCH_STYLE_MASK; + if ( $fetch_mode !== $base_fetch_style ) { + return false; + } + + return in_array( + $base_fetch_style, + array( + PDO::FETCH_OBJ, + PDO::FETCH_ASSOC, + PDO::FETCH_NUM, + PDO::FETCH_BOTH, + ), + true + ); + } + 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; + } + 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; + } + 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 ); + } + 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( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN, $row ) ) { + throw new PDOException( 'Failed to read PostgreSQL FOUND_ROWS accounting result.' ); + } + return (int) $row[ self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ]; + } + private function get_sql_calc_found_rows_select_parts( string $query, bool $allow_distinct, bool $require_sql_calc, ?array &$query_context = null ): ?array { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $projection_start = 1; + $has_distinct = false; + if ( WP_MySQL_Lexer::DISTINCT_SYMBOL === ( $tokens[ $projection_start ]->id ?? null ) ) { + if ( ! $allow_distinct ) { + return null; + } + $has_distinct = true; + ++$projection_start; + } + + $has_sql_calc_found_rows = WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === ( $tokens[ $projection_start ]->id ?? null ); + if ( $has_sql_calc_found_rows ) { + ++$projection_start; + } elseif ( $require_sql_calc ) { + return null; + } + + $clauses = $this->get_mysql_query_context_select_clause_positions( $query_context, $projection_start, $select['statement_end'] ); + if ( null === $clauses ) { + return null; + } + return array( + 'tokens' => $tokens, + 'projection_start' => $projection_start, + 'statement_end' => $select['statement_end'], + 'clauses' => $clauses, + 'from_position' => $clauses['from_position'], + 'has_distinct' => $has_distinct, + 'has_sql_calc_found_rows' => $has_sql_calc_found_rows, + 'limit_position' => $clauses['limit_position'], + 'order_position' => $clauses['order_position'], + 'select_end' => $clauses['select_end'], + ); + } + private function get_sql_calc_found_rows_count_query( string $query ): ?string { + $cache_key = sha1( $this->db_name . "\0" . implode( ',', $this->active_sql_modes ) . "\0" . $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 $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ]['sql']; + } + + $count_plan = $this->get_sql_calc_found_rows_count_plan( $query ); + if ( null === $count_plan ) { + return null; + } + + $count_query = $this->get_sql_calc_found_rows_direct_count_query( $count_plan ); + if ( null === $count_query ) { + $select_query = $this->translate_strict_aggregate_grouped_order_by_query( $count_plan['source_query'], false ) + ?? $this->translate_distinct_order_by_query( $count_plan['source_query'], false ) + ?? $this->translate_sql_calc_found_rows_select_query( $count_plan['source_query'], false ); + if ( null === $select_query ) { + return null; + } + + $count_query = sprintf( + 'SELECT COUNT(*) AS %1$s FROM (%2$s) AS %1$s', + $count_plan['quoted_column'], + $select_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 ); + return $count_query; + } + private function get_sql_calc_found_rows_count_plan( string $query ): ?array { + $select = $this->get_sql_calc_found_rows_select_parts( $query, true, false ); + if ( null === $select ) { + return null; + } + + $count_end = $select['order_position'] ?? $select['select_end']; + $source_query = rtrim( substr( $query, 0, $select['tokens'][ $count_end ]->start ) ); + $source_select = $this->get_sql_calc_found_rows_select_parts( $source_query, true, false ); + return null === $source_select ? null : array( + 'quoted_column' => $this->connection->quote_identifier( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ), + 'source_query' => $source_query, + 'source_select' => $source_select, + ); + } + private function translate_sql_calc_found_rows_window_select_query( string $query, ?array &$query_context = null ): ?string { + if ( false !== stripos( $query, self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ) ) { + return null; + } + + $select = $this->get_sql_calc_found_rows_select_parts( $query, false, true, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $projection_start = $select['projection_start']; + $select_end = $select['select_end']; + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + $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 = $select['from_position']; + 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 + ) + ); + + return $this->append_mysql_select_limit_sql( $sql, $tokens, $select['limit_position'], $select['statement_end'], true ); + } + 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; + } + 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; + } + } + private function get_sql_calc_found_rows_direct_count_query( array $count_plan ): ?string { + $select = $count_plan['source_select']; + $tokens = $select['tokens']; + $projection_start = $select['projection_start']; + $statement_end = $select['statement_end']; + if ( + $select['has_distinct'] + || null !== $select['limit_position'] + || null !== $select['order_position'] + || null !== $select['clauses']['group_position'] + || null !== $select['clauses']['having_position'] + || $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::INTO_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL ) + ) + ) { + return null; + } + + $from_position = $select['from_position']; + 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', + $count_plan['quoted_column'], + $this->translate_sql_calc_found_rows_direct_count_source_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + 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'], + ), + ) + ); + } + private function execute_postgresql_statements( array $statements ) { + if ( empty( $statements ) ) { + return 0; + } + + foreach ( $statements as $statement ) { + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $this->last_result = $this->execute_postgresql_logged_statement( $statement ); + } + + $this->last_column_meta = array(); + return $this->last_result; + } + private function prepend_postgresql_mysql_helper_type_statements( array $statements, ?string $metadata_query, bool $use_catalog ): array { + if ( ! $use_catalog || null === $metadata_query ) { + return $statements; + } + + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + return array_merge( + $translator->get_postgresql_mysql_helper_type_statements( $metadata_query ), + $statements + ); + } + private function execute_postgresql_side_effect_statements( array $statements ): void { + foreach ( $statements as $statement ) { + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + } + } + private function execute_postgresql_logged_statement( string $statement, bool $close_cursor = false ): int { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $row_count = $stmt->rowCount(); + if ( $close_cursor ) { + $stmt->closeCursor(); + } + return $row_count; + } + private function execute_translated_dml_statements( array $dml_query, ?int $return_value = null ): int { + if ( ! isset( $dml_query['statements'] ) || ! is_array( $dml_query['statements'] ) ) { + return 0; + } + + $affected_rows = 0; + foreach ( $dml_query['statements'] as $statement ) { + $statement = (string) $statement; + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $affected_rows += $this->execute_postgresql_logged_statement( $statement ); + } + + $this->clear_last_column_meta(); + $this->last_result = $return_value ?? $affected_rows; + $this->set_last_insert_id_after_dml_success( $dml_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_query, $affected_rows ); + return (int) $this->last_result; + } + private function execute_materialized_mysql_upsert_select_statements( array $upsert_query ): int { + return $this->execute_materialized_mysql_select_dml_statements( $upsert_query, 'upsert' ); + } + private function execute_materialized_mysql_select_dml_statements( array $dml_query, string $operation ): int { + $materialize_statements = isset( $dml_query['materialize_statements'] ) && is_array( $dml_query['materialize_statements'] ) + ? $dml_query['materialize_statements'] + : array(); + $mutation_statements = isset( $dml_query['mutation_statements'] ) && is_array( $dml_query['mutation_statements'] ) + ? $dml_query['mutation_statements'] + : array(); + $cleanup_statements = isset( $dml_query['cleanup_statements'] ) && is_array( $dml_query['cleanup_statements'] ) + ? $dml_query['cleanup_statements'] + : array(); + + foreach ( $materialize_statements as $statement ) { + $this->execute_postgresql_logged_statement( (string) $statement, true ); + } + + $return_value = null; + try { + if ( 'upsert' === $operation ) { + $dml_query = $this->prepare_materialized_mysql_upsert_select_insert_id_metadata( $dml_query ); + if ( ! empty( $dml_query['upsert_select_ambiguous_conflict_targets'] ) ) { + $affected_rows = $this->execute_materialized_mysql_upsert_select_rows_with_ambiguous_targets( $dml_query ); + } + } + + if ( ! isset( $affected_rows ) ) { + $has_duplicate = $this->materialized_mysql_select_dml_has_duplicate_rows( $dml_query ); + if ( $has_duplicate ) { + if ( 'upsert' === $operation ) { + $affected_rows = $this->execute_materialized_mysql_upsert_select_rows_sequentially( $dml_query ); + } else { + $affected_rows = $this->execute_materialized_mysql_replace_select_rows_sequentially( $dml_query ); + $return_value = $affected_rows; + $dml_query['inserted_new_row'] = $affected_rows > 0; + } + } else { + if ( 'replace' === $operation && isset( $dml_query['replace_select_affected_rows_sql'] ) && is_string( $dml_query['replace_select_affected_rows_sql'] ) ) { + $stmt = $this->connection->query( $dml_query['replace_select_affected_rows_sql'] ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $stmt->closeCursor(); + if ( is_array( $row ) && isset( $row['affected_rows'] ) ) { + $return_value = (int) $row['affected_rows']; + $dml_query['inserted_new_row'] = isset( $row['inserted_rows'] ) && (int) $row['inserted_rows'] > 0; + } + } + + $affected_rows = $this->execute_postgresql_logged_statements( $mutation_statements ); + } + } + } finally { + foreach ( $cleanup_statements as $statement ) { + $this->execute_postgresql_logged_statement( (string) $statement, true ); + } + } + return $this->finish_materialized_mysql_select_dml_statements( $dml_query, $affected_rows, $return_value ); + } + private function materialized_mysql_select_dml_has_duplicate_rows( array $dml_query ): bool { + if ( ! isset( $dml_query['duplicate_conflict_rows_sql'] ) || ! is_string( $dml_query['duplicate_conflict_rows_sql'] ) ) { + return false; + } + + $stmt = $this->connection->query( $dml_query['duplicate_conflict_rows_sql'] ); + $has_duplicate = false !== $stmt->fetchColumn(); + $stmt->closeCursor(); + return $has_duplicate; + } + private function execute_postgresql_logged_statements( array $statements ): int { + $affected_rows = 0; + foreach ( $statements as $statement ) { + $affected_rows += $this->execute_postgresql_logged_statement( (string) $statement ); + } + return $affected_rows; + } + private function finish_materialized_mysql_select_dml_statements( array $dml_query, int $affected_rows, ?int $return_value ): int { + $this->clear_last_column_meta(); + $this->last_result = $return_value ?? $affected_rows; + $this->set_last_insert_id_after_dml_success( $dml_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_query, $affected_rows ); + return (int) $this->last_result; + } + private function prepare_materialized_mysql_upsert_select_insert_id_metadata( array $upsert_query ): array { + if ( + ! isset( $upsert_query['table_name'], $upsert_query['columns'], $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_array( $upsert_query['columns'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + ) { + return $upsert_query; + } + + if ( isset( $upsert_query['last_insert_id_column_on_duplicate_key_update'] ) ) { + $last_insert_id_row = $this->get_materialized_mysql_upsert_select_last_insert_id_row( + $upsert_query, + (string) $upsert_query['last_insert_id_column_on_duplicate_key_update'] + ); + if ( null === $last_insert_id_row ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + + if ( $last_insert_id_row['found'] ) { + $upsert_query['last_insert_id_on_duplicate_key_update'] = $last_insert_id_row['value']; + } + } + + if ( empty( $upsert_query['insert_id_unknown'] ) ) { + return $upsert_query; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( $upsert_query['table_name'] ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column || ! $this->mysql_dml_column_list_contains_column( $upsert_query['columns'], $auto_increment_column ) ) { + return $upsert_query; + } + + $insert_id_value_rows = $this->get_materialized_mysql_upsert_select_insert_id_value_rows( + $upsert_query, + $auto_increment_column + ); + if ( null === $insert_id_value_rows ) { + return $upsert_query; + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( + $auto_increment_column, + $upsert_query['columns'], + $insert_id_value_rows + ); + if ( null === $explicit_insert_id ) { + return $upsert_query; + } + + $upsert_query['insert_id_value_rows'] = $insert_id_value_rows; + $upsert_query['insert_id_unknown'] = false; + return $upsert_query; + } + private function get_materialized_mysql_upsert_select_last_insert_id_row( array $upsert_query, string $column_name ): ?array { + if ( + empty( $upsert_query['conflict_parts'] ) + || ! is_array( $upsert_query['conflict_parts'] ) + || ! isset( $upsert_query['table_name'], $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + ) { + return null; + } + + $stmt = $this->connection->query( sprintf( 'SELECT COUNT(*) FROM %s', $upsert_query['source_table_sql'] ) ); + $total_rows = $stmt->fetchColumn(); + $stmt->closeCursor(); + if ( ! is_numeric( $total_rows ) ) { + return null; + } + + $total_rows = (int) $total_rows; + if ( 0 === $total_rows ) { + return array( + 'found' => false, + 'value' => null, + ); + } + + $target_alias = $this->connection->quote_identifier( '__wp_pg_upsert_target' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $ordinal = $this->connection->quote_identifier( '__wp_pg_upsert_insert_id_ordinal' ); + $predicate = $this->get_materialized_mysql_dml_conflict_group_predicate_sql( + $target_alias, + $rows_alias, + $upsert_query['conflict_parts'] + ); + if ( null === $predicate ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s.%2$s FROM (SELECT ROW_NUMBER() OVER () AS %3$s, %4$s.* FROM %5$s AS %4$s) AS %6$s, %7$s AS %1$s WHERE %8$s ORDER BY %6$s.%3$s', + $target_alias, + $this->connection->quote_identifier( $column_name ), + $ordinal, + $source_alias, + $upsert_query['source_table_sql'], + $rows_alias, + $this->connection->quote_identifier( $upsert_query['table_name'] ), + $predicate + ) + ); + $values = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + if ( count( $values ) !== $total_rows ) { + return 0 === count( $values ) + ? array( + 'found' => false, + 'value' => null, + ) + : null; + } + return array( + 'found' => true, + 'value' => $values[ count( $values ) - 1 ], + ); + } + private function get_materialized_mysql_upsert_select_insert_id_value_rows( array $upsert_query, string $auto_increment_column ): ?array { + $auto_increment_index = null; + foreach ( $upsert_query['columns'] as $index => $column ) { + if ( 0 === strcasecmp( (string) $column, $auto_increment_column ) ) { + $auto_increment_index = $index; + break; + } + } + if ( null === $auto_increment_index ) { + return null; + } + + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $ordinal = $this->connection->quote_identifier( '__wp_pg_upsert_insert_id_ordinal' ); + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s.%2$s FROM (SELECT ROW_NUMBER() OVER () AS %3$s, %4$s.* FROM %5$s AS %4$s) AS %1$s ORDER BY %1$s.%3$s', + $rows_alias, + $this->connection->quote_identifier( $auto_increment_column ), + $ordinal, + $source_alias, + $upsert_query['source_table_sql'] + ) + ); + $values = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $value_rows = array(); + foreach ( $values as $value ) { + $row = array_fill( 0, count( $upsert_query['columns'] ), 'DEFAULT' ); + $row[ $auto_increment_index ] = null === $value ? 'NULL' : (string) $value; + $value_rows[] = $row; + } + return $value_rows; + } + private function execute_materialized_mysql_upsert_select_rows_with_ambiguous_targets( array $upsert_query ): int { + if ( + ! isset( $upsert_query['conflict_targets'], $upsert_query['assignments'] ) + || ! is_array( $upsert_query['conflict_targets'] ) + || ! is_array( $upsert_query['assignments'] ) + ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + return $this->execute_materialized_mysql_upsert_select_rows( + $upsert_query, + function ( int $ordinal_value ) use ( $upsert_query ): ?string { + $conflict_target = $this->get_materialized_mysql_upsert_select_conflict_target_for_ordinal( $upsert_query, $ordinal_value ); + if ( null === $conflict_target ) { + return null; + } + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $upsert_query['assignments'] ) + ); + } + ); + } + private function get_materialized_mysql_upsert_select_conflict_target_for_ordinal( array $upsert_query, int $ordinal ): ?array { + $conflict_targets = isset( $upsert_query['conflict_targets'] ) && is_array( $upsert_query['conflict_targets'] ) + ? $upsert_query['conflict_targets'] + : array(); + if ( empty( $conflict_targets ) ) { + return null; + } + + $target_alias = $this->connection->quote_identifier( '__wp_pg_upsert_target' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $ordinal_sql = sprintf( + '%s.%s = %d', + $rows_alias, + $this->connection->quote_identifier( '__wp_pg_upsert_ordinal' ), + $ordinal + ); + + $matching_targets = array(); + foreach ( $conflict_targets as $conflict_target ) { + $predicate_sql = $this->get_materialized_mysql_dml_conflict_group_predicate_sql( + $target_alias, + $rows_alias, + $conflict_target['parts'] ?? array() + ); + if ( null === $predicate_sql ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s AS %s, %s AS %s WHERE %s AND %s LIMIT 1', + $this->connection->quote_identifier( (string) $upsert_query['table_name'] ), + $target_alias, + $upsert_query['ordinal_source_table_sql'], + $rows_alias, + $ordinal_sql, + $predicate_sql + ) + ); + $conflict_exists = false !== $stmt->fetchColumn(); + $stmt->closeCursor(); + if ( $conflict_exists ) { + $matching_targets[] = $conflict_target; + } + + if ( count( $matching_targets ) > 1 ) { + return null; + } + } + return $matching_targets[0] ?? $conflict_targets[0]; + } + private function get_materialized_mysql_dml_conflict_group_predicate_sql( ?string $target_alias, string $rows_alias, array $conflict_parts ): ?string { + $where = array(); + foreach ( $conflict_parts as $conflict_part ) { + $column = (string) ( $conflict_part['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $target_value = null === $target_alias + ? $this->connection->quote_identifier( $column ) + : sprintf( + '%s.%s', + $target_alias, + $this->connection->quote_identifier( $column ) + ); + $incoming_value = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + if ( null !== ( $conflict_part['sub_part'] ?? null ) && '' !== (string) $conflict_part['sub_part'] ) { + $target_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $target_value, + (int) $conflict_part['sub_part'] + ); + $incoming_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_value, + (int) $conflict_part['sub_part'] + ); + } + + $where[] = sprintf( '%s = %s', $target_value, $incoming_value ); + } + return empty( $where ) ? null : implode( ' AND ', $where ); + } + private function execute_materialized_mysql_upsert_select_rows_sequentially( array $upsert_query ): int { + if ( + ! isset( $upsert_query['conflict_sql'] ) + || ! is_string( $upsert_query['conflict_sql'] ) + ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + return $this->execute_materialized_mysql_upsert_select_rows( + $upsert_query, + static function () use ( $upsert_query ): string { + return $upsert_query['conflict_sql']; + } + ); + } + private function execute_materialized_mysql_upsert_select_rows( array $upsert_query, callable $get_conflict_sql ): int { + return $this->execute_materialized_mysql_select_rows( + $upsert_query, + 'upsert', + function ( int $ordinal_value, string $row_filter, array $context ) use ( $get_conflict_sql ): array { + $conflict_sql = $get_conflict_sql( $ordinal_value ); + if ( null === $conflict_sql ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + return array( + sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE %s %s', + $context['quoted_target_table'], + $context['column_sql'], + implode( ', ', $context['insert_projection_sql'] ), + $context['ordinal_source_table_sql'], + $context['rows_alias'], + $row_filter, + $conflict_sql + ), + ); + } + ); + } + private function execute_materialized_mysql_select_rows( array $dml_query, string $operation, callable $get_row_statements ): int { + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'], $dml_query['source_table_sql'], $dml_query['ordinal_source_table_sql'] ) + || ! is_string( $dml_query['table_name'] ) + || ! is_array( $dml_query['columns'] ) + || ! is_string( $dml_query['source_table_sql'] ) + || ! is_string( $dml_query['ordinal_source_table_sql'] ) + ) { + throw new InvalidArgumentException( 'Unsupported materialized SELECT DML statement.' ); + } + + $ordinal_column = '__wp_pg_' . $operation . '_ordinal'; + $quoted_ordinal = $this->connection->quote_identifier( $ordinal_column ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_' . $operation . '_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_' . $operation . '_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_' . $operation . '_target' ); + $quoted_target_table = $this->connection->quote_identifier( $dml_query['table_name'] ); + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $dml_query['columns'] ) ); + + $insert_projection_sql = array(); + foreach ( $dml_query['columns'] as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( (string) $column ) + ); + } + + $create_ordinal_table_sql = sprintf( + 'CREATE TEMPORARY TABLE %s AS SELECT ROW_NUMBER() OVER () AS %s, %s.* FROM %s AS %s', + $dml_query['ordinal_source_table_sql'], + $quoted_ordinal, + $source_alias, + $dml_query['source_table_sql'], + $source_alias + ); + $this->execute_postgresql_logged_statement( $create_ordinal_table_sql, true ); + + $drop_ordinal_table_sql = sprintf( 'DROP TABLE IF EXISTS %s', $dml_query['ordinal_source_table_sql'] ); + + try { + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s ORDER BY %1$s', + $quoted_ordinal, + $dml_query['ordinal_source_table_sql'] + ) + ); + $ordinals = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $context = array( + 'column_sql' => $column_sql, + 'insert_projection_sql' => $insert_projection_sql, + 'ordinal_source_table_sql' => $dml_query['ordinal_source_table_sql'], + 'quoted_ordinal' => $quoted_ordinal, + 'quoted_target_table' => $quoted_target_table, + 'rows_alias' => $rows_alias, + 'target_alias' => $target_alias, + ); + + $affected_rows = 0; + foreach ( $ordinals as $ordinal ) { + $ordinal_value = (int) $ordinal; + $row_filter = sprintf( '%s.%s = %d', $rows_alias, $quoted_ordinal, $ordinal_value ); + $affected_rows += $this->execute_postgresql_logged_statements( $get_row_statements( $ordinal_value, $row_filter, $context ) ); + } + return $affected_rows; + } finally { + $this->execute_postgresql_logged_statement( $drop_ordinal_table_sql, true ); + } + } + private function execute_materialized_mysql_replace_select_statements( array $replace_query ): int { + return $this->execute_materialized_mysql_select_dml_statements( $replace_query, 'replace' ); + } + private function execute_materialized_mysql_replace_select_rows_sequentially( array $replace_query ): int { + if ( + ! isset( $replace_query['conflict_index_groups'] ) + || ! is_array( $replace_query['conflict_index_groups'] ) + ) { + throw new InvalidArgumentException( 'Unsupported REPLACE SELECT statement.' ); + } + + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $replace_query['conflict_index_groups'] + ); + if ( null === $delete_predicate_sql ) { + throw new InvalidArgumentException( 'Unsupported REPLACE SELECT statement.' ); + } + return $this->execute_materialized_mysql_select_rows( + $replace_query, + 'replace', + static function ( int $ordinal_value, string $row_filter, array $context ) use ( $delete_predicate_sql ): array { + return array( + sprintf( + 'DELETE FROM %s AS %s WHERE EXISTS (SELECT 1 FROM %s AS %s WHERE %s AND %s)', + $context['quoted_target_table'], + $context['target_alias'], + $context['ordinal_source_table_sql'], + $context['rows_alias'], + $row_filter, + $delete_predicate_sql + ), + sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE %s', + $context['quoted_target_table'], + $context['column_sql'], + implode( ', ', $context['insert_projection_sql'] ), + $context['ordinal_source_table_sql'], + $context['rows_alias'], + $row_filter + ), + ); + } + ); + } + private function execute_mysql_multi_target_delete_query( string $statement ): int { + return $this->execute_mysql_affected_rows_result_query( $statement ); + } + private function execute_mysql_multi_target_update_query( string $statement ): int { + return $this->execute_mysql_affected_rows_result_query( $statement ); + } + private function execute_mysql_affected_rows_result_query( string $statement ): int { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $this->clear_last_column_meta(); + $this->last_result = isset( $row['affected_rows'] ) ? (int) $row['affected_rows'] : 0; + return $this->last_result; + } + private function execute_mysql_update_ignore_query( string $statement, bool $expects_affected_rows_result = false ): int { + try { + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + } catch ( PDOException $e ) { + if ( ! $this->is_mysql_update_ignore_constraint_exception( $e ) ) { + throw $e; + } + + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->clear_last_column_meta(); + $this->last_result = 0; + return $this->last_result; + } + + if ( $expects_affected_rows_result ) { + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $this->clear_last_column_meta(); + $this->last_result = isset( $row['affected_rows'] ) ? (int) $row['affected_rows'] : 0; + return $this->last_result; + } + + $this->clear_last_column_meta(); + $this->last_result = $stmt->rowCount(); + return $this->last_result; + } + 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(); + return $this->finalize_mysql_transaction_control_query( 'BEGIN' ); + } + + if ( ! $in_transaction ) { + return $this->finalize_mysql_transaction_control_query(); + } + + if ( 'COMMIT' === $statement ) { + $pdo->commit(); + } else { + $pdo->rollBack(); + } + return $this->finalize_mysql_transaction_control_query( $statement ); + } + private function finalize_mysql_transaction_control_query( ?string $logged_statement = null ): int { + $this->connection->reset_statement_savepoint_state(); + if ( null !== $logged_statement ) { + $this->last_postgresql_queries[] = array( + 'sql' => $logged_statement, + 'params' => array(), + ); + } + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + 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 ) ) { + return $this->execute_mysql_admin_noop_query(); + } + + $operations = $this->get_mysql_set_assignment_operations( $tokens ); + if ( null === $operations ) { + throw new InvalidArgumentException( 'Unsupported SET statement.' ); + } + + foreach ( $operations as $operation ) { + if ( 'user' === $operation['target_type'] ) { + $this->mysql_user_variables[ $operation['name'] ] = $operation['value']; + continue; + } + + if ( 'global' === ( $operation['scope'] ?? null ) ) { + $value = $operation['value']; + if ( 'sql_mode' === $operation['name'] ) { + $value = implode( ',', $this->normalize_mysql_sql_modes( $value ) ); + } + $this->mysql_global_variable_values[ $operation['name'] ] = $value; + continue; + } + + if ( 'sql_mode' === $operation['name'] ) { + $this->set_sql_mode( $operation['value'] ); + continue; + } + + $this->mysql_session_variable_values[ $operation['name'] ] = $operation['value']; + } + + return $this->execute_mysql_admin_noop_query(); + } + 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; + } + 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; + } + private function get_mysql_set_assignment_operations( array $tokens ): ?array { + $position = 1; + $statement_scope = $this->get_mysql_set_statement_scope( $tokens, $position ); + $ops = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $target = $this->parse_mysql_set_assignment_target( $tokens, $position ); + if ( null === $target ) { + return null; + } + if ( 'system' === $target['type'] && null === ( $target['scope'] ?? null ) ) { + $target['scope'] = $statement_scope; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $operation = $this->parse_mysql_set_assignment_operation( $tokens, $position, $target ); + if ( null === $operation ) { + return null; + } + + $ops[] = $operation; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( array() === $ops || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + return $ops; + } + private function get_mysql_set_statement_scope( array $tokens, int &$position ): ?string { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + self::MYSQL_SYSTEM_VARIABLE_SCOPE_TOKENS, + true + ) + ) { + return strtolower( $tokens[ $position++ ]->get_value() ); + } + return null; + } + private function parse_mysql_set_assignment_target( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return array( + 'type' => 'user', + 'name' => $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ), + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $name || ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + return array( + 'type' => 'system', + 'name' => $name, + 'scope' => $scope, + ); + } + + $name = strtolower( $tokens[ $position ]->get_value() ); + if ( ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + ++$position; + return array( + 'type' => 'system', + 'name' => $name, + 'scope' => null, + ); + } + private function parse_mysql_set_assignment_operation( array $tokens, int &$position, array $target ): ?array { + $value = $this->parse_mysql_set_assignment_value( $tokens, $position, $target ); + if ( null === $value ) { + return null; + } + + if ( 'system' === $target['type'] ) { + $value = $this->normalize_mysql_system_variable_assignment_value( + $target['name'], + $value, + $target['scope'] ?? null + ); + if ( null === $value ) { + return null; + } + } + return array( + 'target_type' => $target['type'], + 'name' => $target['name'], + 'value' => $value, + 'scope' => $target['scope'] ?? null, + ); + } + private function parse_mysql_set_assignment_value( array $tokens, int &$position, array $target ): ?string { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + $start = $position; + $end = null; + $depth = 0; + for ( $i = $start; isset( $tokens[ $i ] ); $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + + continue; + } + + if ( + 0 === $depth + && in_array( $tokens[ $i ]->id, self::MYSQL_SET_ASSIGNMENT_VALUE_BOUNDARY_TOKENS, true ) + ) { + $end = $i; + break; + } + } + if ( null === $end || $start === $end ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $user_variable_name = $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id ) { + return $this->parse_mysql_user_variable_increment_value( + $tokens, + $position, + $target, + $user_variable_name + ); + } + + if ( $position !== $end ) { + return null; + } + return $this->get_mysql_user_variable_value( $user_variable_name ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $display = null; + $scope = null; + $system_variable_name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $system_variable_name || $position !== $end ) { + return null; + } + return $this->get_mysql_system_variable_value( $system_variable_name, $scope ); + } + + if ( 'system' === $target['type'] && 'sql_mode' === $target['name'] ) { + $value = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $start, $end ); + if ( null !== $value ) { + $position = $end; + return $value; + } + } + + if ( $start + 1 !== $end ) { + return null; + } + + $value = $this->get_mysql_set_literal_token_value( $tokens[ $start ] ); + if ( null !== $value ) { + $position = $end; + } + return $value; + } + private function evaluate_mysql_sql_mode_set_expression( array $tokens, int $start, int $end ): ?string { + while ( + $start < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( $after_close !== $end ) { + break; + } + + if ( isset( $tokens[ $start + 1 ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $start + 1 ]->id ) { + return $this->evaluate_mysql_sql_mode_set_select_expression( $tokens, $start + 1, $end - 1 ); + } + + ++$start; + --$end; + } + + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + if ( $start + 1 === $end ) { + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $start ]->id ) { + return $this->get_mysql_user_variable_value( $this->normalize_mysql_user_variable_name( $tokens[ $start ]->get_value() ) ); + } + return $this->get_mysql_set_literal_token_value( $tokens[ $start ] ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $start ]->id ) { + $position = $start; + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null !== $name && $position === $end ) { + return $this->get_mysql_system_variable_value( $name, $scope ); + } + } + return $this->evaluate_mysql_sql_mode_set_function_expression( $tokens, $start, $end ); + } + private function evaluate_mysql_sql_mode_set_select_expression( array $tokens, int $start, int $end ): ?string { + if ( ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $projection_start = $start + 1; + $projection_end = $end; + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $end ); + if ( null !== $from_position ) { + if ( + ! isset( $tokens[ $from_position + 1 ] ) + || WP_MySQL_Lexer::DUAL_SYMBOL !== $tokens[ $from_position + 1 ]->id + || $from_position + 2 !== $end + ) { + return null; + } + + $projection_end = $from_position; + } + return $this->evaluate_mysql_sql_mode_set_expression( $tokens, $projection_start, $projection_end ); + } + private function evaluate_mysql_sql_mode_set_function_expression( array $tokens, int $start, int $end ): ?string { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ); + if ( $after_close !== $end ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end - 1 ); + if ( null === $arguments ) { + return null; + } + + $function = strtolower( $tokens[ $start ]->get_value() ); + $is_case_conversion = in_array( $function, array( 'lcase', 'lower', 'ucase', 'upper' ), true ); + $argument_count = $is_case_conversion ? 1 : ( in_array( $function, array( 'replace', 'regexp_replace' ), true ) ? 3 : null ); + if ( ( null === $argument_count && 'concat' !== $function ) || ( null !== $argument_count && count( $arguments ) !== $argument_count ) ) { + return null; + } + + $values = array(); + foreach ( $arguments as $argument ) { + $value = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $argument['start'], $argument['end'] ); + if ( null === $value ) { + return null; + } + + $values[] = $value; + } + + if ( $is_case_conversion ) { + return in_array( $function, array( 'lcase', 'lower' ), true ) ? strtolower( $values[0] ) : strtoupper( $values[0] ); + } + + if ( 'concat' === $function ) { + return implode( '', $values ); + } + + if ( 'replace' === $function ) { + return str_replace( $values[1], $values[2], $values[0] ); + } + + $result = @preg_replace( '/' . str_replace( '/', '\/', $values[1] ) . '/', $values[2], $values[0] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Unsupported regular expressions should evaluate to null, not emit warnings. + return null === $result ? null : $result; + } + 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 ); + } + private function get_mysql_set_literal_token_value( WP_MySQL_Token $token ): ?string { + if ( in_array( $token->id, self::MYSQL_SET_LITERAL_DISALLOWED_TOKENS, true ) ) { + return null; + } + return $token->get_value(); + } + 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; + } + 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 ) { + return 'SAVEPOINT ' . $this->get_mysql_quoted_savepoint_name( $tokens, 1 ); + } + + if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $tokens[0]->id ) { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WORK_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return 'ROLLBACK TO SAVEPOINT ' . $this->get_mysql_quoted_savepoint_name( $tokens, $position ); + } + + 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; + return 'RELEASE SAVEPOINT ' . $this->get_mysql_quoted_savepoint_name( $tokens, $position ); + } + return null; + } + private function get_mysql_quoted_savepoint_name( array $tokens, int $position ): string { + $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 $this->connection->quote_identifier( $name ); + } + private function execute_mysql_truncate_table_query( array $truncate_table_query ): int { + $requested_schema = $truncate_table_query['schema']; + $table_name = $truncate_table_query['table']; + + $targets_unsupported_schema = ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 !== strcasecmp( $requested_schema, $this->main_db_name ) ); + if ( $targets_unsupported_schema ) { + throw new InvalidArgumentException( 'Unsupported TRUNCATE TABLE statement.' ); + } + + $statement = 'TRUNCATE TABLE ' . $this->connection->quote_identifier( $table_name ) . ' RESTART IDENTITY'; + + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + $this->mysql_introspection_result_cache = array(); + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + private function clear_mysql_metadata_caches(): void { + $this->mysql_table_schema_introspection_cache = array(); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); + $this->mysql_column_metadata_introspection_cache = array(); + $this->mysql_show_create_table_metadata_introspection_cache = array(); + $this->mysql_dml_identity_repair_eligibility_cache = array(); + $this->mysql_unique_index_metadata_introspection_cache = array(); + $this->mysql_select_translation_cache = array(); + $this->mysql_select_translation_last_cache = null; + $this->mysql_meta_priming_select_template_cache = array(); + $this->mysql_sql_calc_found_rows_count_query_cache = array(); + } + + private function get_postgresql_catalog_mysql_schema_metadata_or_fail( string $query ): array { + $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ) )->extract_schema_metadata( $query, true ); + if ( empty( $metadata_tables ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for CREATE TABLE statement.' ); + } + + foreach ( $metadata_tables as $metadata ) { + foreach ( $metadata['columns'] ?? array() as $column ) { + if ( + ! $this->is_postgresql_catalog_recoverable_mysql_column_type( (string) ( $column['type'] ?? '' ) ) + || ! $this->is_postgresql_catalog_recoverable_mysql_column_extra( $column['extra'] ?? '', $column ) + ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for CREATE TABLE statement.' ); + } + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_index_metadata( $index ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for CREATE TABLE statement.' ); + } + } + + foreach ( $metadata['checks'] ?? array() as $check ) { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_check_metadata( $check ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for CREATE TABLE statement.' ); + } + } + } + return $metadata_tables; + } + private function is_postgresql_catalog_recoverable_mysql_column_type( string $column_type ): bool { + $column_type = strtolower( trim( $column_type ) ); + + if ( '' === $column_type ) { + return false; + } + + $integer_domain_type = null; + if ( 1 === preg_match( '/^(bit|bool|boolean|tinyint|smallint|mediumint|int|int1|int2|int3|int4|int8|integer|bigint)(?:\(\d+\))?(?: unsigned)?$/', $column_type, $matches ) ) { + $integer_domain_type = 'integer' === $matches[1] ? 'int' : $matches[1]; + } + + if ( + in_array( $column_type, array( 'int', 'bigint', 'text' ), true ) + || ( + null !== $integer_domain_type + && isset( self::MYSQL_INTEGER_DOMAIN_BASE_TYPES[ $integer_domain_type ] ) + && ! in_array( $column_type, array( 'int', 'bigint' ), true ) + ) + ) { + return true; + } + + if ( in_array( $column_type, self::MYSQL_TEXT_DOMAIN_TYPES, true ) ) { + return true; + } + + if ( 1 === preg_match( '/^(?:var)?binary(?:\(\d+\))?$|^(?:tinyblob|blob|mediumblob|longblob)$/', $column_type ) ) { + return true; + } + + if ( 1 === preg_match( '/^(?:dec|fixed)(?:\(\d+(?:,\d+)?\))?$|^float(?:\(\d+(?:,\d+)?\))?$|^real$|^double\(\d+(?:,\d+)?\)$|^numeric\(\d+(?:,\d+)?\)$/', $column_type ) ) { + return true; + } + + if ( $this->is_mysql_spatial_column_type( $column_type ) ) { + return true; + } + + if ( 1 === preg_match( '/^enum\(.+\)$/', $column_type ) ) { + return true; + } + + if ( 1 === preg_match( '/^set\(.+\)$/', $column_type ) ) { + return true; + } + return (bool) preg_match( '/^(?:var)?char(?:\(\d+\))?$|^year(?: unsigned)?$|^(?:dec|fixed|numeric|decimal)(?:\(\d+(?:,\d+)?\))?(?: unsigned)?$|^(?:double|float|real)(?:\(\d+(?:,\d+)?\))?(?: unsigned)?$/', $column_type ); + } + private function is_postgresql_catalog_recoverable_mysql_index_metadata( array $index ): bool { + $index_type = strtoupper( (string) ( $index['index_type'] ?? 'BTREE' ) ); + if ( + $this->is_mysql_metadata_only_index_type( $index_type ) + && '1' !== (string) ( $index['non_unique'] ?? '1' ) + ) { + return false; + } + + foreach ( $index['columns'] ?? array() as $column ) { + $collation = strtoupper( (string) ( $column['collation'] ?? 'A' ) ); + if ( 'FULLTEXT' === $index_type && '' === $collation ) { + continue; + } + + if ( ! in_array( $collation, array( 'A', 'D' ), true ) ) { + return false; + } + } + return true; + } + private function assert_postgresql_catalog_recoverable_mysql_index_metadata( array $index ): void { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_index_metadata( $index ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for index statement.' ); + } + } + private function sync_mysql_schema_catalog_side_effects_for_schema( array $metadata_tables, $table_schema ): void { + $this->clear_mysql_metadata_caches(); + + 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']; + + $table_comment = (string) ( $metadata['comment'] ?? '' ); + $table_collation = (string) ( $metadata['collation'] ?? '' ); + if ( '' !== $this->get_postgresql_catalog_table_comment( $table_comment, $table_collation ) ) { + $this->sync_postgresql_catalog_table_comment( + $schema_name, + $table_name, + $table_comment, + $table_collation + ); + } + + foreach ( $metadata['columns'] as $column ) { + $column_comment = $this->get_postgresql_catalog_column_comment( $column ); + if ( '' !== $column_comment ) { + $this->sync_postgresql_catalog_column_comment( + $schema_name, + $table_name, + (string) $column['name'], + $column_comment + ); + } + + $this->sync_postgresql_catalog_identity_sequence_comment( + $schema_name, + $table_name, + $column + ); + + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $column['extra'] ?? '' ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_create_statements( $schema_name, $table_name, (string) $column['name'] ) + ); + } + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + if ( $this->is_mysql_metadata_only_index_type( (string) ( $index['index_type'] ?? 'BTREE' ) ) ) { + $statement = $this->get_postgresql_catalog_metadata_only_index_create_statement( + $schema_name, + $table_name, + $index + ); + if ( null !== $statement ) { + $this->execute_postgresql_side_effect_statements( array( $statement ) ); + } + } + + $this->sync_postgresql_catalog_index_comment( $schema_name, $table_name, $index, true ); + } + + foreach ( $metadata['checks'] ?? array() as $check ) { + $this->sync_postgresql_catalog_check_comment( $schema_name, $table_name, $check ); + } + } + } + private function seed_mysql_column_metadata_introspection_cache_for_created_tables( array $metadata_tables, $table_schema, string $metadata_query ): void { + if ( $this->mysql_create_metadata_query_has_explicit_default_null( $metadata_query ) ) { + return; + } + + foreach ( $metadata_tables as $metadata ) { + if ( empty( $metadata['table_name'] ) || ! is_string( $metadata['table_name'] ) ) { + continue; + } + + $schema_name = is_callable( $table_schema ) + ? (string) call_user_func( $table_schema, $metadata['table_name'] ) + : (string) $table_schema; + if ( '' === $schema_name ) { + continue; + } + + $rows = $this->get_mysql_column_metadata_introspection_rows_from_create_metadata( $metadata ); + if ( null === $rows ) { + continue; + } + + $this->mysql_column_metadata_introspection_cache[ $schema_name . "\0" . $metadata['table_name'] ] = $rows; + } + } + private function seed_mysql_show_create_table_metadata_introspection_cache_for_created_tables( array $metadata_tables, $table_schema, string $metadata_query ): void { + if ( $this->mysql_create_metadata_query_has_explicit_default_null( $metadata_query ) ) { + return; + } + + foreach ( $metadata_tables as $metadata ) { + if ( empty( $metadata['table_name'] ) || ! is_string( $metadata['table_name'] ) ) { + continue; + } + + $schema_name = is_callable( $table_schema ) + ? (string) call_user_func( $table_schema, $metadata['table_name'] ) + : (string) $table_schema; + if ( '' === $schema_name ) { + continue; + } + + $show_create_metadata = $this->get_show_create_table_metadata_from_create_metadata( $metadata ); + if ( null === $show_create_metadata ) { + continue; + } + + $table_name = $metadata['table_name']; + $this->mysql_show_create_table_metadata_introspection_cache[ $schema_name . "\0" . $table_name ] = $show_create_metadata; + if ( 'public' !== $schema_name && ! $this->is_mysql_temporary_schema_name( $schema_name ) ) { + continue; + } + + $this->mysql_table_schema_introspection_cache[ 'public' . "\0" . $table_name ] = $schema_name; + } + } + private function get_show_create_table_metadata_from_create_metadata( array $metadata ): ?array { + $columns = $this->get_show_create_table_column_metadata_rows_from_create_metadata( $metadata['columns'] ?? array() ); + if ( null === $columns ) { + return null; + } + + $indexes = $this->get_show_create_table_index_metadata_rows_from_create_metadata( $metadata['indexes'] ?? array() ); + $foreign_keys = $this->get_show_create_table_foreign_key_metadata_rows_from_create_metadata( $metadata['foreign_keys'] ?? array() ); + $checks = $this->get_show_create_table_check_metadata_rows_from_create_metadata( $metadata['checks'] ?? array() ); + if ( null === $indexes || null === $foreign_keys || null === $checks ) { + return null; + } + + return array( + 'columns' => $columns, + 'indexes' => $indexes, + 'foreign_keys' => $foreign_keys, + 'checks' => $checks, + 'table' => array( + 'comment' => (string) ( $metadata['comment'] ?? '' ), + 'collation' => $this->get_show_create_table_collation_from_create_metadata( + (string) ( $metadata['collation'] ?? self::DEFAULT_MYSQL_COLLATION ), + $columns + ), + ), + ); + } + private function get_show_create_table_collation_from_create_metadata( string $table_collation, array $columns ): string { + $table_collation = strtolower( trim( $table_collation ) ); + if ( '' !== $table_collation && self::DEFAULT_MYSQL_COLLATION !== $table_collation ) { + return $table_collation; + } + + foreach ( $columns as $column ) { + $column_collation = $column['collation_name'] ?? null; + if ( is_string( $column_collation ) && '' !== $column_collation ) { + return $column_collation; + } + } + + return '' === $table_collation ? self::DEFAULT_MYSQL_COLLATION : $table_collation; + } + private function get_show_create_table_column_metadata_rows_from_create_metadata( array $columns ): ?array { + if ( empty( $columns ) ) { + return null; + } + + $rows = array(); + $column_name_index = array(); + $expected_ordinal = 1; + foreach ( $columns as $column ) { + if ( ! is_array( $column ) ) { + return null; + } + + foreach ( array( 'name', 'type', 'charset', 'collation', 'nullable', 'default', 'extra', 'comment', 'ordinal' ) as $required_field ) { + if ( ! array_key_exists( $required_field, $column ) ) { + return null; + } + } + + $column_name = (string) $column['name']; + $column_type = (string) $column['type']; + if ( + '' === $column_name + || '' === trim( $column_type ) + || (int) $column['ordinal'] !== $expected_ordinal + || ! $this->is_postgresql_catalog_recoverable_mysql_column_type( $column_type ) + || ! $this->is_postgresql_catalog_recoverable_mysql_column_extra( $column['extra'], $column ) + ) { + return null; + } + + $column_name_key = strtolower( $column_name ); + if ( isset( $column_name_index[ $column_name_key ] ) ) { + return null; + } + + $is_nullable = strtoupper( (string) $column['nullable'] ); + if ( ! in_array( $is_nullable, array( 'YES', 'NO' ), true ) ) { + return null; + } + + $column_default = $column['default']; + if ( null !== $column_default && ! is_scalar( $column_default ) ) { + return null; + } + + $column_name_index[ $column_name_key ] = true; + $rows[] = array( + 'column_name' => $column_name, + 'ordinal_position' => (string) $expected_ordinal, + 'column_type' => $column_type, + 'character_set_name' => null === $column['charset'] ? null : (string) $column['charset'], + 'collation_name' => null === $column['collation'] ? null : (string) $column['collation'], + 'is_nullable' => $is_nullable, + 'column_default' => null === $column_default ? null : (string) $column_default, + 'extra' => (string) $column['extra'], + 'column_comment' => (string) $column['comment'], + ); + ++$expected_ordinal; + } + return $rows; + } + private function get_show_create_table_index_metadata_rows_from_create_metadata( array $indexes ): ?array { + foreach ( $indexes as $index ) { + if ( ! is_array( $index ) ) { + return null; + } + } + + usort( + $indexes, + static function ( array $left, array $right ): int { + $left_primary = 'PRIMARY' === strtoupper( (string) ( $left['name'] ?? '' ) ) ? 0 : 1; + $right_primary = 'PRIMARY' === strtoupper( (string) ( $right['name'] ?? '' ) ) ? 0 : 1; + if ( $left_primary !== $right_primary ) { + return $left_primary <=> $right_primary; + } + + $left_unique = '0' === (string) ( $left['non_unique'] ?? '1' ) ? 0 : 1; + $right_unique = '0' === (string) ( $right['non_unique'] ?? '1' ) ? 0 : 1; + if ( $left_unique !== $right_unique ) { + return $left_unique <=> $right_unique; + } + + return (int) ( $left['ordinal'] ?? 0 ) <=> (int) ( $right['ordinal'] ?? 0 ); + } + ); + + $rows = array(); + foreach ( $indexes as $index ) { + foreach ( array( 'name', 'ordinal', 'non_unique', 'index_type', 'comment', 'columns' ) as $required_field ) { + if ( ! array_key_exists( $required_field, $index ) ) { + return null; + } + } + + if ( ! is_array( $index['columns'] ) || ! $this->is_postgresql_catalog_recoverable_mysql_index_metadata( $index ) ) { + return null; + } + + foreach ( $index['columns'] as $column ) { + if ( ! is_array( $column ) || ! array_key_exists( 'column_name', $column ) || ! array_key_exists( 'seq_in_index', $column ) ) { + return null; + } + + $rows[] = array( + 'key_name' => (string) $index['name'], + 'index_ordinal' => (string) $index['ordinal'], + 'seq_in_index' => (string) $column['seq_in_index'], + 'column_name' => (string) $column['column_name'], + 'non_unique' => (string) $index['non_unique'], + 'index_type' => (string) $index['index_type'], + 'collation' => null === ( $column['collation'] ?? null ) ? null : (string) $column['collation'], + 'sub_part' => null === ( $column['sub_part'] ?? null ) ? null : (string) $column['sub_part'], + 'index_comment' => (string) $index['comment'], + ); + } + } + return $rows; + } + private function get_show_create_table_foreign_key_metadata_rows_from_create_metadata( array $foreign_keys ): ?array { + $rows = array(); + $constraint_ordinal = 1; + foreach ( $foreign_keys as $foreign_key ) { + if ( ! is_array( $foreign_key ) ) { + return null; + } + + foreach ( array( 'name', 'columns', 'referenced_table', 'referenced_columns', 'update_rule', 'delete_rule' ) as $required_field ) { + if ( ! array_key_exists( $required_field, $foreign_key ) ) { + return null; + } + } + + if ( + ! is_array( $foreign_key['columns'] ) + || ! is_array( $foreign_key['referenced_columns'] ) + || count( $foreign_key['columns'] ) !== count( $foreign_key['referenced_columns'] ) + ) { + return null; + } + + foreach ( $foreign_key['columns'] as $offset => $column_name ) { + $rows[] = array( + 'constraint_name' => (string) $foreign_key['name'], + 'constraint_ordinal' => (string) $constraint_ordinal, + 'seq_in_index' => (string) ( $offset + 1 ), + 'column_name' => (string) $column_name, + 'referenced_table_schema' => null === ( $foreign_key['referenced_schema'] ?? null ) ? null : (string) $foreign_key['referenced_schema'], + 'referenced_table_name' => (string) $foreign_key['referenced_table'], + 'referenced_column_name' => (string) $foreign_key['referenced_columns'][ $offset ], + 'update_rule' => (string) $foreign_key['update_rule'], + 'delete_rule' => (string) $foreign_key['delete_rule'], + ); + } + ++$constraint_ordinal; + } + return $rows; + } + private function get_show_create_table_check_metadata_rows_from_create_metadata( array $checks ): ?array { + $rows = array(); + $constraint_ordinal = 1; + foreach ( $checks as $check ) { + if ( ! is_array( $check ) ) { + return null; + } + + foreach ( array( 'name', 'check_clause', 'enforced' ) as $required_field ) { + if ( ! array_key_exists( $required_field, $check ) ) { + return null; + } + } + + if ( ! $this->is_postgresql_catalog_recoverable_mysql_check_metadata( $check ) ) { + return null; + } + + $rows[] = array( + 'constraint_name' => (string) $check['name'], + 'constraint_ordinal' => (string) $constraint_ordinal, + 'check_clause' => (string) $check['check_clause'], + 'enforced' => (string) $check['enforced'], + ); + ++$constraint_ordinal; + } + return $rows; + } + private function mysql_create_metadata_query_has_explicit_default_null( string $metadata_query ): bool { + $tokens = $this->get_mysql_tokens( $metadata_query ); + foreach ( $tokens as $position => $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL !== $token->id ) { + continue; + } + + $next = $position + 1; + while ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $next ]->id ?? null ) ) { + ++$next; + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === ( $tokens[ $next ]->id ?? null ) ) { + return true; + } + } + return false; + } + private function get_mysql_column_metadata_introspection_rows_from_create_metadata( array $metadata ): ?array { + if ( empty( $metadata['columns'] ) || ! is_array( $metadata['columns'] ) ) { + return null; + } + + $rows = array(); + $column_name_index = array(); + $ordinal = 1; + foreach ( $metadata['columns'] as $column ) { + if ( ! is_array( $column ) ) { + return null; + } + + $row = $this->get_mysql_column_metadata_introspection_row_from_create_column_metadata( $column, $ordinal ); + if ( null === $row ) { + return null; + } + + $column_name_key = strtolower( $row['column_name'] ); + if ( isset( $column_name_index[ $column_name_key ] ) ) { + return null; + } + + $column_name_index[ $column_name_key ] = true; + $rows[] = $row; + ++$ordinal; + } + return $rows; + } + private function get_mysql_column_metadata_introspection_row_from_create_column_metadata( array $column, int $expected_ordinal ): ?array { + foreach ( array( 'name', 'ordinal', 'type', 'collation', 'nullable', 'default', 'extra' ) as $required_field ) { + if ( ! array_key_exists( $required_field, $column ) ) { + return null; + } + } + + $column_name = (string) $column['name']; + $column_type = (string) $column['type']; + if ( + '' === $column_name + || '' === trim( $column_type ) + || (int) $column['ordinal'] !== $expected_ordinal + || ! $this->is_postgresql_catalog_recoverable_mysql_column_type( $column_type ) + || ! $this->is_postgresql_catalog_recoverable_mysql_column_extra( $column['extra'], $column ) + ) { + return null; + } + + $is_nullable = strtoupper( (string) $column['nullable'] ); + if ( ! in_array( $is_nullable, array( 'YES', 'NO' ), true ) ) { + return null; + } + + $column_default = $column['default']; + if ( null !== $column_default && ! is_scalar( $column_default ) ) { + return null; + } + + $extra = $column['extra']; + if ( ! is_scalar( $extra ) ) { + return null; + } + + $collation = $column['collation']; + if ( $this->is_mysql_column_metadata_preseed_collatable_column_type( $column_type ) ) { + if ( ! is_string( $collation ) || '' === trim( $collation ) ) { + return null; + } + + $collation = strtolower( trim( $collation ) ); + } elseif ( null !== $collation ) { + return null; + } + + return array( + 'column_name' => $column_name, + 'ordinal_position' => (string) $expected_ordinal, + 'column_type' => $column_type, + 'collation_name' => $collation, + 'is_nullable' => $is_nullable, + 'column_default' => null === $column_default ? null : (string) $column_default, + 'extra' => (string) $extra, + ); + } + private function is_mysql_column_metadata_preseed_collatable_column_type( string $column_type ): bool { + return $this->is_mysql_text_family_column_type( $column_type ) + || 1 === preg_match( '/^\s*(?:enum|set)\s*\(/i', $column_type ); + } + private function mysql_column_extra_has_on_update_current_timestamp( ?string $extra ): bool { + return null !== $extra && false !== stripos( $extra, 'on update CURRENT_TIMESTAMP' ); + } + private function mysql_column_extra_has_default_generated( ?string $extra ): bool { + return null !== $extra && false !== stripos( $extra, 'DEFAULT_GENERATED' ); + } + private function get_postgresql_on_update_current_timestamp_create_statements( string $table_schema, string $table_name, string $column_name ): array { + $trigger_name = $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $table_name, $column_name ); + $function_name = $this->get_postgresql_on_update_current_timestamp_function_name( $table_schema, $table_name, $column_name ); + $table_sql = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $function_sql = $this->get_postgresql_schema_identifier( $table_schema, $function_name ); + $column_sql = $this->connection->quote_identifier( $column_name ); + $column_value = $this->connection->quote( $column_name ); + return array( + sprintf( + 'CREATE OR REPLACE FUNCTION %s() +RETURNS trigger +LANGUAGE plpgsql +AS $wp_mysql_on_update$ +BEGIN + IF NEW.%s IS NOT DISTINCT FROM OLD.%s + AND to_jsonb(NEW) - %s IS DISTINCT FROM to_jsonb(OLD) - %s THEN + NEW.%s = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'); + END IF; + RETURN NEW; +END; +$wp_mysql_on_update$', + $function_sql, + $column_sql, + $column_sql, + $column_value, + $column_value, + $column_sql + ), + sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->connection->quote_identifier( $trigger_name ), + $table_sql + ), + sprintf( + 'CREATE TRIGGER %s BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION %s()', + $this->connection->quote_identifier( $trigger_name ), + $table_sql, + $function_sql + ), + ); + } + private function get_postgresql_on_update_current_timestamp_drop_statements( string $table_schema, string $table_name, string $column_name ): array { + $trigger_name = $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $table_name, $column_name ); + $function_name = $this->get_postgresql_on_update_current_timestamp_function_name( $table_schema, $table_name, $column_name ); + return array( + sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->connection->quote_identifier( $trigger_name ), + $this->get_postgresql_schema_identifier( $table_schema, $table_name ) + ), + sprintf( + 'DROP FUNCTION IF EXISTS %s()', + $this->get_postgresql_schema_identifier( $table_schema, $function_name ) + ), + ); + } + private function postgresql_on_update_current_timestamp_trigger_exists( string $table_schema, string $table_name, string $column_name ): bool { + $stmt = $this->connection->query( + 'SELECT 1 + FROM pg_catalog.pg_trigger tr + INNER JOIN pg_catalog.pg_class c + ON c.oid = tr.tgrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + AND tr.tgname = ? + AND NOT tr.tgisinternal + LIMIT 1', + array( + $table_schema, + $table_name, + $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $table_name, $column_name ), + ) + ); + return false !== $stmt->fetchColumn(); + } + private function get_postgresql_on_update_current_timestamp_trigger_name( string $table_schema, string $table_name, string $column_name ): string { + return '__wp_pg_on_update_' . md5( $table_schema . "\0" . $table_name . "\0" . $column_name ); + } + private function get_postgresql_on_update_current_timestamp_function_name( string $table_schema, string $table_name, string $column_name ): string { + return '__wp_pg_on_update_fn_' . md5( $table_schema . "\0" . $table_name . "\0" . $column_name ); + } + private function get_postgresql_on_update_current_timestamp_trigger_hash_sql( string $table_schema_sql, string $table_name_sql, string $column_name_sql ): string { + return sprintf( + 'md5(convert_to(%1$s, \'UTF8\') || decode(\'00\', \'hex\') || convert_to(%2$s, \'UTF8\') || decode(\'00\', \'hex\') || convert_to(%3$s, \'UTF8\'))', + $table_schema_sql, + $table_name_sql, + $column_name_sql + ); + } + 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(); + } + private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { + $table_schema = $metadata['schema'] ?? 'public'; + $table_name = $metadata['table']; + $operation = $metadata['operation']; + $table_context = array( 'schema' => $table_schema ) + array( 'table' => $table_name ); + switch ( $operation ) { + case 'operations': + $this->apply_mysql_dbdelta_alter_metadata_operations( $metadata['operations'], $table_context ); + return; + + case 'noop': + case 'set_auto_increment': + return; + + case 'rename_table': + if ( $table_name !== $metadata['new_table'] ) { + $this->clear_mysql_metadata_caches(); + } + return; + + case 'rename_index': + if ( $metadata['old_index'] !== $metadata['new_index'] ) { + $this->clear_mysql_metadata_caches(); + } + return; + + case 'drop_index': + case 'add_foreign_key': + case 'drop_foreign_key': + case 'drop_check': + $this->clear_mysql_metadata_caches(); + return; + + case 'add_index': + $index = $metadata['index']; + $this->assert_postgresql_catalog_recoverable_mysql_index_metadata( $index ); + $this->clear_mysql_metadata_caches(); + $this->sync_postgresql_catalog_index_comment( $table_schema, $table_name, $index, true ); + return; + + case 'set_table_comment': + $table_comment = (string) ( $metadata['comment'] ?? '' ); + $logged_queries = $this->last_postgresql_queries; + try { + $table_metadata = $this->get_show_create_table_table_metadata( $table_schema, $table_name ); + } finally { + $this->last_postgresql_queries = $logged_queries; + } + $table_collation = (string) ( $table_metadata['collation'] ?? '' ); + $this->sync_postgresql_catalog_table_comment( $table_schema, $table_name, $table_comment, $table_collation ); + $this->clear_mysql_metadata_caches(); + return; + + case 'add_check': + $check = $metadata['check']; + if ( ! $this->is_postgresql_catalog_recoverable_mysql_check_metadata( $check ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for ALTER TABLE statement.' ); + } + $this->sync_postgresql_catalog_check_comment( $table_schema, $table_name, $check ); + $this->clear_mysql_metadata_caches(); + return; + + case 'add_column': + $this->sync_mysql_column_catalog_side_effects( $table_schema, $table_name, $metadata ); + return; + + case 'rename_column': + if ( $this->postgresql_on_update_current_timestamp_trigger_exists( $table_schema, $table_name, $metadata['old_column'] ) ) { + $this->execute_postgresql_side_effect_statements( + array_merge( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $metadata['old_column'] ), + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $table_name, $metadata['new_column'] ) + ) + ); + } + $this->clear_mysql_metadata_caches(); + return; + + case 'drop_column': + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $metadata['column'] ) + ); + $this->clear_mysql_metadata_caches(); + return; + + case 'set_default': + case 'drop_default': + $this->apply_mysql_column_default_metadata( $table_schema, $table_name, $metadata['column'] ); + return; + + case 'change_column': + $this->sync_mysql_column_catalog_side_effects( $table_schema, $table_name, $metadata, $metadata['old_column'] ); + return; + + case 'change_columns': + $this->apply_mysql_dbdelta_alter_metadata_operations( $metadata['columns'], array( 'operation' => 'change_column' ) + $table_context ); + } + } + private function apply_mysql_dbdelta_alter_metadata_operations( $operations, array $operation_context ): void { + foreach ( $operations as $operation_metadata ) { + foreach ( $operation_context as $name => $value ) { + $operation_metadata[ $name ] = $value; + } + $this->apply_mysql_dbdelta_alter_metadata( $operation_metadata ); + } + } + private function sync_mysql_column_catalog_side_effects( string $table_schema, string $table_name, array $metadata, ?string $old_column = null ): void { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_column_metadata( $metadata ) ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata for ALTER TABLE statement.' ); + } + + $column = $metadata['column']; + if ( null !== $old_column && $this->postgresql_on_update_current_timestamp_trigger_exists( $table_schema, $table_name, $old_column ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $old_column ) + ); + } + + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $column['extra'] ?? '' ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $table_name, $column['name'] ) + ); + } + + $column_comment = $this->get_postgresql_catalog_column_comment( $column ); + if ( null !== $old_column || '' !== $column_comment ) { + $this->sync_postgresql_catalog_column_comment( + $table_schema, + $table_name, + (string) $column['name'], + $column_comment + ); + } + + $this->sync_postgresql_catalog_identity_sequence_comment( $table_schema, $table_name, $column ); + foreach ( $metadata['checks'] ?? array() as $check ) { + $this->sync_postgresql_catalog_check_comment( $table_schema, $table_name, $check ); + } + $this->clear_mysql_metadata_caches(); + } + private function mysql_dbdelta_alter_metadata_has_operation( array $metadata, string $operation ): bool { + if ( ( $metadata['operation'] ?? '' ) === $operation ) { + return true; + } + + if ( 'operations' !== ( $metadata['operation'] ?? '' ) ) { + return false; + } + + foreach ( $metadata['operations'] ?? array() as $operation_metadata ) { + if ( is_array( $operation_metadata ) && $this->mysql_dbdelta_alter_metadata_has_operation( $operation_metadata, $operation ) ) { + return true; + } + } + return false; + } + private function is_postgresql_catalog_recoverable_mysql_column_metadata( array $metadata ): bool { + foreach ( $metadata['indexes'] ?? array() as $index ) { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_index_metadata( $index ) ) { + return false; + } + } + + foreach ( $metadata['checks'] ?? array() as $check ) { + if ( ! $this->is_postgresql_catalog_recoverable_mysql_check_metadata( $check ) ) { + return false; + } + } + + $column = $metadata['column']; + if ( ! $this->is_postgresql_catalog_recoverable_mysql_column_type( (string) ( $column['type'] ?? '' ) ) ) { + return false; + } + return $this->is_postgresql_catalog_recoverable_mysql_column_extra( $column['extra'] ?? '', $column ); + } + private function is_postgresql_catalog_recoverable_mysql_column_extra( ?string $extra, array $column ): bool { + $extra = strtolower( trim( (string) $extra ) ); + if ( '' === $extra ) { + return true; + } + + if ( 'auto_increment' === $extra ) { + return $this->is_mysql_integer_family_column_type( (string) ( $column['type'] ?? '' ) ); + } + + $has_default_generated = $this->mysql_column_extra_has_default_generated( $extra ); + $has_on_update = $this->mysql_column_extra_has_on_update_current_timestamp( $extra ); + $remaining_extra = trim( + (string) preg_replace( + array( + '/\bdefault_generated\b/i', + '/\bon\s+update\s+current_timestamp(?:\([0-6]\))?\b/i', + ), + ' ', + $extra + ) + ); + + if ( '' !== $remaining_extra ) { + return false; + } + + if ( + $has_default_generated + && ! array_key_exists( 'default', $column ) + ) { + return false; + } + + if ( + $has_on_update + && ! preg_match( '/^(?:datetime|timestamp)(?:\([0-6]\))?$/i', strtolower( trim( (string) ( $column['type'] ?? '' ) ) ) ) + ) { + return false; + } + return $has_default_generated || $has_on_update; + } + private function is_postgresql_catalog_recoverable_mysql_check_metadata( array $check ): bool { + $enforced = strtoupper( (string) ( $check['enforced'] ?? 'YES' ) ); + if ( ! in_array( $enforced, array( 'YES', 'NO' ), true ) ) { + return false; + } + return '' !== trim( (string) ( $check['check_clause'] ?? '' ) ); + } + private function apply_mysql_column_default_metadata( string $table_schema, string $table_name, string $column_name ): void { + $sql = 'SELECT pg_catalog.col_description(c.oid, a.attnum) AS column_comment + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + INNER JOIN pg_catalog.pg_attribute a + ON a.attrelid = c.oid + WHERE n.nspname = ? + AND c.relname = ? + AND c.relkind IN (\'r\', \'p\', \'v\', \'m\') + AND a.attname = ? + AND a.attnum > 0 + LIMIT 1'; + $params = array( $table_schema, $table_name, $column_name ); + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + $current_comment = $stmt->fetchColumn(); + if ( false !== $current_comment && null !== $current_comment ) { + $lines = explode( "\n", (string) $current_comment ); + foreach ( array( 0, 1 ) as $offset ) { + if ( + isset( $lines[ $offset ] ) + && $this->is_postgresql_catalog_base64_marker_line( $lines[ $offset ], self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX ) + ) { + unset( $lines[ $offset ] ); + break; + } + } + + $clean_comment = implode( "\n", array_values( $lines ) ); + if ( $clean_comment !== $current_comment ) { + $this->sync_postgresql_catalog_column_comment( $table_schema, $table_name, $column_name, $clean_comment ); + } + } + + $this->clear_mysql_metadata_caches(); + } + private function sync_postgresql_catalog_table_comment( string $table_schema, string $table_name, string $table_comment, string $table_collation = '' ): void { + $this->execute_postgresql_side_effect_statements( + array( + sprintf( + 'COMMENT ON TABLE %s IS %s', + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ), + $this->get_postgresql_catalog_comment_literal( + $this->get_postgresql_catalog_table_comment( $table_comment, $table_collation ) + ) + ), + ) + ); + } + private function sync_postgresql_catalog_column_comment( string $table_schema, string $table_name, string $column_name, string $column_comment ): void { + $this->execute_postgresql_side_effect_statements( + array( + sprintf( + 'COMMENT ON COLUMN %s.%s IS %s', + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ), + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_catalog_comment_literal( $column_comment ) + ), + ) + ); + } + private function get_postgresql_catalog_column_comment( array $column ): string { + $comment = (string) ( $column['comment'] ?? '' ); + $metadata_lines = array(); + + if ( + $this->mysql_column_extra_has_default_generated( $column['extra'] ?? '' ) + && array_key_exists( 'default', $column ) + && null !== $column['default'] + ) { + $metadata_lines[] = self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX . base64_encode( (string) $column['default'] ); + } + + $column_type = strtolower( trim( (string) ( $column['type'] ?? '' ) ) ); + if ( 1 === preg_match( '/^year unsigned$|^(?:dec|fixed|numeric|decimal)(?:\(\d+(?:,\d+)?\))? unsigned$|^(?:double|float|real)(?:\(\d+(?:,\d+)?\))? unsigned$/', $column_type ) ) { + $metadata_lines[] = self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX . base64_encode( (string) $column['type'] ); + } + + $charset = strtolower( trim( (string) ( $column['charset'] ?? '' ) ) ); + $collation = strtolower( trim( (string) ( $column['collation'] ?? '' ) ) ); + if ( + '' !== $charset + && ( self::DEFAULT_MYSQL_CHARSET !== $charset || self::DEFAULT_MYSQL_COLLATION !== $collation ) + ) { + $metadata_lines[] = self::MYSQL_COLUMN_COMMENT_CHARSET_PREFIX . base64_encode( $charset ); + } + + if ( + '' !== $collation + && self::DEFAULT_MYSQL_COLLATION !== $collation + ) { + $metadata_lines[] = self::MYSQL_COLUMN_COMMENT_COLLATION_PREFIX . base64_encode( $collation ); + } + + if ( '' !== $comment ) { + $metadata_lines[] = $this->get_postgresql_catalog_escaped_user_comment( $comment ); + } + return implode( "\n", $metadata_lines ); + } + private function sync_postgresql_catalog_identity_sequence_comment( string $table_schema, string $table_name, array $column ): void { + $column_type = (string) ( $column['type'] ?? '' ); + $identity_column_type = (string) preg_replace( '/\s+/', ' ', strtolower( trim( $column_type ) ) ); + if ( + 'auto_increment' !== strtolower( (string) ( $column['extra'] ?? '' ) ) + || ! $this->is_mysql_integer_family_column_type( $column_type ) + || in_array( $identity_column_type, array( 'int', 'integer', 'bigint' ), true ) + ) { + return; + } + + $this->execute_postgresql_side_effect_statements( + array( + sprintf( + 'DO $wp_mysql_identity_sequence_comment$ +DECLARE + identity_sequence regclass; +BEGIN + identity_sequence := pg_catalog.pg_get_serial_sequence(format(\'%%I.%%I\', %1$s, %2$s), %3$s)::regclass; + IF identity_sequence IS NOT NULL THEN + EXECUTE format(\'COMMENT ON SEQUENCE %%s IS %%L\', identity_sequence, %4$s); + END IF; +END; +$wp_mysql_identity_sequence_comment$', + $this->connection->quote( $table_schema ), + $this->connection->quote( $table_name ), + $this->connection->quote( (string) $column['name'] ), + $this->connection->quote( self::MYSQL_IDENTITY_SEQUENCE_COMMENT_TYPE_PREFIX . strtolower( trim( (string) $column['type'] ) ) ) + ), + ) + ); + } + private function sync_postgresql_catalog_check_comment( string $table_schema, string $table_name, array $check ): void { + $metadata_lines = array(); + $check_clause = trim( (string) ( $check['check_clause'] ?? '' ) ); + $postgresql_check_clause = array_key_exists( 'postgresql_check_clause', $check ) + ? trim( (string) $check['postgresql_check_clause'] ) + : $check_clause; + $enforced = strtoupper( (string) ( $check['enforced'] ?? 'YES' ) ); + if ( '' !== $check_clause && ( 'NO' === $enforced || $check_clause !== $postgresql_check_clause ) ) { + $metadata_lines[] = self::MYSQL_CHECK_CONSTRAINT_COMMENT_CLAUSE_PREFIX . $check_clause; + } + if ( 'NO' === $enforced ) { + $metadata_lines[] = self::MYSQL_CHECK_CONSTRAINT_COMMENT_ENFORCED_PREFIX . 'NO'; + } + + $constraint_comment = implode( "\n", $metadata_lines ); + if ( '' === $constraint_comment ) { + return; + } + + $this->execute_postgresql_side_effect_statements( + array( + sprintf( + 'COMMENT ON CONSTRAINT %s ON %s IS %s', + $this->connection->quote_identifier( (string) $check['name'] ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ), + $this->get_postgresql_catalog_comment_literal( $constraint_comment ) + ), + ) + ); + } + private function get_postgresql_catalog_table_comment( string $table_comment, string $table_collation = '' ): string { + $metadata_lines = array(); + $table_collation = strtolower( trim( $table_collation ) ); + + if ( '' !== $table_collation && self::DEFAULT_MYSQL_COLLATION !== $table_collation ) { + $metadata_lines[] = self::MYSQL_TABLE_COMMENT_COLLATION_PREFIX . base64_encode( $table_collation ); + } + + if ( '' !== $table_comment ) { + $metadata_lines[] = $this->get_postgresql_catalog_escaped_user_comment( $table_comment ); + } + return implode( "\n", $metadata_lines ); + } + private function get_postgresql_catalog_escaped_user_comment( string $comment ): string { + $lines = explode( "\n", $comment ); + foreach ( $lines as $index => $line ) { + if ( $this->is_postgresql_catalog_user_comment_escape_required( $line ) ) { + $line = self::POSTGRESQL_CATALOG_USER_COMMENT_ESCAPE_PREFIX . $line; + } + $lines[ $index ] = $line; + } + return implode( "\n", $lines ); + } + private function is_postgresql_catalog_user_comment_escape_required( string $line ): bool { + foreach ( array_merge( array( self::POSTGRESQL_CATALOG_USER_COMMENT_ESCAPE_PREFIX ), self::POSTGRESQL_CATALOG_COMMENT_MARKER_PREFIXES ) as $prefix ) { + if ( 0 === strpos( $line, $prefix ) ) { + return true; + } + } + return false; + } + private function is_postgresql_catalog_base64_marker_line( string $line, string $prefix ): bool { + if ( 0 !== strpos( $line, $prefix ) ) { + return false; + } + + $payload = substr( $line, strlen( $prefix ) ); + if ( '' === $payload || 1 !== preg_match( self::POSTGRESQL_CATALOG_BASE64_MARKER_PAYLOAD_PATTERN, $payload ) ) { + return false; + } + return false !== base64_decode( $payload, true ); + } + private function get_postgresql_catalog_comment_literal( string $comment ): string { + return '' === $comment ? 'NULL' : $this->connection->quote( $comment ); + } + private function get_postgresql_mysql_check_clause_comment_sql( string $comment_sql, string $fallback_sql ): string { + $prefix = $this->connection->quote( self::MYSQL_CHECK_CONSTRAINT_COMMENT_CLAUSE_PREFIX ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $comment_sql ); + return sprintf( + 'CASE + WHEN LEFT(%1$s, LENGTH(%2$s)) = %2$s THEN split_part(SUBSTRING(%1$s FROM LENGTH(%2$s) + 1), CHR(10), 1) + ELSE %3$s +END', + $comment_sql, + $prefix, + $fallback_sql + ); + } + private function get_postgresql_mysql_check_enforced_comment_sql( string $comment_sql ): string { + $prefix = $this->connection->quote( self::MYSQL_CHECK_CONSTRAINT_COMMENT_ENFORCED_PREFIX ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $comment_sql ); + $second_marker_sql = sprintf( 'CHR(10) || %s', $prefix ); + $first_payload_sql = sprintf( 'split_part(SUBSTRING(%1$s FROM LENGTH(%2$s) + 1), CHR(10), 1)', $comment_sql, $prefix ); + $second_payload_sql = sprintf( + 'split_part(SUBSTRING(%1$s FROM POSITION(%2$s IN %1$s) + LENGTH(%2$s)), CHR(10), 1)', + $comment_sql, + $second_marker_sql + ); + return sprintf( + 'CASE + WHEN LEFT(%1$s, LENGTH(%2$s)) = %2$s AND UPPER(%3$s) = \'NO\' THEN \'NO\' + WHEN POSITION(%4$s IN %1$s) > 0 AND UPPER(%5$s) = \'NO\' THEN \'NO\' + ELSE \'YES\' +END', + $comment_sql, + $prefix, + $first_payload_sql, + $second_marker_sql, + $second_payload_sql + ); + } + private function sync_postgresql_catalog_index_comment( string $table_schema, string $table_name, array $index, bool $skip_empty = false ): void { + $comment_lines = array(); + $index_name = (string) $index['name']; + $is_primary = 'PRIMARY' === strtoupper( $index_name ); + $index_comment = (string) ( $index['comment'] ?? '' ); + $index_type = strtoupper( (string) ( $index['index_type'] ?? 'BTREE' ) ); + $is_metadata_only = $this->is_mysql_metadata_only_index_type( $index_type ); + if ( $is_primary || ! $is_metadata_only ) { + foreach ( $index['columns'] ?? array() as $column ) { + if ( null === ( $column['sub_part'] ?? null ) || '' === (string) $column['sub_part'] ) { + continue; + } + $comment_lines[] = self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX . (int) ( $column['seq_in_index'] ?? count( $comment_lines ) + 1 ) . ':' . (int) $column['sub_part']; + } + } + if ( ! $is_primary && $is_metadata_only ) { + array_unshift( $comment_lines, self::MYSQL_INDEX_COMMENT_TYPE_PREFIX . base64_encode( $index_type ) ); + } + if ( ! $is_primary && '' !== $index_comment ) { + $comment_lines[] = $this->get_postgresql_catalog_escaped_user_comment( $index_comment ); + } + if ( $is_primary || ! empty( $comment_lines ) ) { + $index_comment = implode( "\n", $comment_lines ); + } + if ( ( $skip_empty && '' === $index_comment ) || ( $is_primary && 0 !== strpos( $index_comment, self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX ) ) ) { + return; + } + if ( $is_primary ) { + $statement = sprintf( + 'DO $wp_mysql_primary_index_comment$ +DECLARE + index_identifier text; +BEGIN + SELECT idx.oid::pg_catalog.regclass::text + INTO index_identifier + 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 + AND i.indisprimary + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + WHERE n.nspname = %1$s + AND t.relname = %2$s + LIMIT 1; + + IF index_identifier IS NOT NULL THEN + EXECUTE pg_catalog.format( + \'COMMENT ON INDEX %%s IS %%L\', + index_identifier, + %3$s + ); + END IF; +END +$wp_mysql_primary_index_comment$', + $this->connection->quote( $table_schema ), + $this->connection->quote( $table_name ), + $this->get_postgresql_catalog_comment_literal( $index_comment ) + ); + } else { + $statement = sprintf( + 'COMMENT ON INDEX %s IS %s', + $this->get_postgresql_qualified_identifier( $table_schema, $table_name . '__' . $index_name ), + $this->get_postgresql_catalog_comment_literal( $index_comment ) + ); + } + $this->execute_postgresql_side_effect_statements( array( $statement ) ); + } + private function mysql_index_metadata_exists( string $table_schema, string $table_name, string $index_name, bool $unique_only = false ): bool { + $stmt = $this->connection->query( + 'WITH ' . $this->get_postgresql_catalog_index_columns_cte_sql( + '', + '', + array_merge( + array( + 'n.nspname = ?', + 't.relname = ?', + 't.relkind IN (\'r\', \'p\')', + ), + $unique_only ? array( 'i.indisunique' ) : array() + ) + ) . ' + SELECT 1 + FROM index_columns + WHERE (indisprimary AND LOWER(?) = \'primary\') + OR ( + NOT indisprimary + AND ( + LOWER(postgresql_index_name) = LOWER(?) + OR LOWER(postgresql_index_name) = LOWER(table_name || \'__\' || ?) + ) + ) + LIMIT 1', + array( $table_schema, $table_name, $index_name, $index_name, $index_name ) + ); + return false !== $stmt->fetchColumn(); + } + private function get_mysql_check_metadata( string $table_schema, string $table_name, string $constraint_name ): ?array { + $check_clause_sql = $this->get_postgresql_mysql_check_clause_comment_sql( + 'pg_catalog.obj_description(con.oid, \'pg_constraint\')', + 'pg_catalog.pg_get_expr(con.conbin, con.conrelid)' + ); + $enforced_sql = $this->get_postgresql_mysql_check_enforced_comment_sql( + 'pg_catalog.obj_description(con.oid, \'pg_constraint\')' + ); + $stmt = $this->connection->query( + sprintf( + 'SELECT + con.conname AS constraint_name, + %1$s AS check_clause, + %2$s AS enforced, + \'catalog\' AS metadata_source + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class t + ON t.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND con.contype = \'c\' + AND LOWER(con.conname) = LOWER(?) + LIMIT 1', + $check_clause_sql, + $enforced_sql + ), + array( $table_schema, $table_name, $constraint_name ) + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + return false === $row ? null : $row; + } + private function get_next_mysql_foreign_key_constraint_name( string $table_schema, string $table_name, array $reserved = array() ): string { + $constraint_names = array_merge( + $reserved, + $this->get_postgresql_catalog_foreign_key_constraint_names( $table_schema, $table_name ) + ); + $max_suffix = 0; + $pattern = '/^' . preg_quote( $table_name, '/' ) . '_ibfk_(\d+)$/i'; + foreach ( $constraint_names as $constraint_name ) { + if ( 1 === preg_match( $pattern, (string) $constraint_name, $matches ) ) { + $max_suffix = max( $max_suffix, (int) $matches[1] ); + } + } + return sprintf( '%s_ibfk_%d', $table_name, $max_suffix + 1 ); + } + private function get_postgresql_catalog_foreign_key_constraint_names( string $table_schema, string $table_name ): array { + $constraint_name_sql = $this->get_postgresql_catalog_foreign_key_constraint_name_sql( 'con.conname', 't.relname' ); + + $stmt = $this->connection->query( + sprintf( + 'SELECT %s AS constraint_name + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class t + ON t.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND con.contype = \'f\'', + $constraint_name_sql + ), + array( $table_schema, $table_name ) + ); + return array_map( 'strval', $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) ); + } + private function get_postgresql_catalog_foreign_key_constraint_name_sql( string $constraint_name_sql, string $table_name_sql ): string { + return sprintf( + 'CASE + WHEN SUBSTRING(%1$s FROM 1 FOR CHAR_LENGTH(%2$s || \'__\')) = %2$s || \'__\' + THEN SUBSTRING(%1$s FROM CHAR_LENGTH(%2$s || \'__\') + 1) + ELSE %1$s + END', + $constraint_name_sql, + $table_name_sql + ); + } + private function mysql_foreign_key_metadata_exists( string $table_schema, string $table_name, string $constraint_name ): bool { + $constraint_name_sql = $this->get_postgresql_catalog_foreign_key_constraint_name_sql( 'con.conname', 't.relname' ); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class t + ON t.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND con.contype = \'f\' + AND LOWER(%s) = LOWER(?) + LIMIT 1', + $constraint_name_sql + ), + array( $table_schema, $table_name, $constraint_name ) + ); + return false !== $stmt->fetchColumn(); + } + private function get_next_mysql_check_constraint_name( string $table_schema, string $table_name, array $reserved = array() ): string { + $constraint_names = array_merge( + $reserved, + $this->get_postgresql_catalog_check_constraint_names( $table_schema, $table_name ) + ); + $prefix = $table_name . '_chk_'; + $max = 0; + + foreach ( $constraint_names as $constraint_name ) { + if ( 1 === preg_match( '/^' . preg_quote( $prefix, '/' ) . '([1-9][0-9]*)$/', (string) $constraint_name, $matches ) ) { + $max = max( $max, (int) $matches[1] ); + } + } + return $prefix . ( $max + 1 ); + } + private function get_postgresql_catalog_check_constraint_names( string $table_schema, string $table_name ): array { + $stmt = $this->connection->query( + 'SELECT con.conname + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class t + ON t.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND con.contype = \'c\'', + array( $table_schema, $table_name ) + ); + return array_map( 'strval', $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) ); + } + private function get_postgresql_primary_key_constraint_name( string $table_schema, string $table_name ): ?string { + $stmt = $this->connection->query( + 'SELECT con.conname + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class t + ON t.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND con.contype = \'p\' + ORDER BY con.conname + LIMIT 1', + array( $table_schema, $table_name ) + ); + + $constraint_name = $stmt->fetchColumn(); + if ( false !== $constraint_name && '' !== (string) $constraint_name ) { + return (string) $constraint_name; + } + return null; + } + private function get_mysql_table_column_type( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $metadata = $this->get_mysql_table_catalog_column_metadata_row( $table_schema, $table_name, $column_name ); + return null === $metadata || ! array_key_exists( 'column_type', $metadata ) + ? null + : (string) $metadata['column_type']; + } + private function get_cached_mysql_table_column_type( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $metadata = $this->get_cached_mysql_table_catalog_column_metadata_row( $table_schema, $table_name, $column_name ); + return null === $metadata || ! array_key_exists( 'column_type', $metadata ) + ? null + : (string) $metadata['column_type']; + } + private function get_cached_mysql_table_column_collation( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $metadata = $this->get_cached_mysql_table_catalog_column_metadata_row( $table_schema, $table_name, $column_name ); + return null === $metadata || null === ( $metadata['collation_name'] ?? null ) ? null : (string) $metadata['collation_name']; + } + private function get_mysql_table_catalog_column_metadata_rows( + string $table_schema, + string $table_name, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + if ( null !== $column_name ) { + return $this->read_mysql_table_catalog_column_metadata_rows( + $table_schema, + $table_name, + $column_name, + $case_sensitive_column + ); + } + + return $this->get_cached_mysql_table_catalog_column_metadata_rows( + $table_schema, + $table_name, + $column_name, + $case_sensitive_column + ); + } + private function get_cached_mysql_table_catalog_column_metadata_rows( + string $table_schema, + string $table_name, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + $cache_key = $table_schema . "\0" . $table_name; + if ( array_key_exists( $cache_key, $this->mysql_column_metadata_introspection_cache ) ) { + return $this->filter_mysql_table_catalog_column_metadata_rows( + $this->mysql_column_metadata_introspection_cache[ $cache_key ], + $column_name, + $case_sensitive_column + ); + } + + $rows = $this->read_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name ); + $this->mysql_column_metadata_introspection_cache[ $cache_key ] = $rows; + return $this->filter_mysql_table_catalog_column_metadata_rows( $rows, $column_name, $case_sensitive_column ); + } + private function read_mysql_table_catalog_column_metadata_rows( + string $table_schema, + string $table_name, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + $column_comment_sql = 'c.column_comment'; + $column_type = $this->get_direct_information_schema_catalog_column_type_expression( + 'c', + 'c.identity_sequence_comment', + $column_comment_sql + ); + $collation = $this->get_direct_information_schema_collation_expression( + $column_type, + 'c.collation_name', + $column_comment_sql, + $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ) + ); + $sql = $this->get_postgresql_catalog_column_metadata_sql( + sprintf( + 'c.column_name, + c.ordinal_position, + %1$s AS column_type, + %2$s AS collation_name, + c.is_nullable, + %3$s AS column_default, + %4$s AS extra', + $column_type, + $collation, + $this->get_direct_information_schema_column_default_expression( 'c', $column_comment_sql ), + $this->get_direct_information_schema_column_extra_expression( 'c', true, $column_comment_sql ) + ), + null !== $column_name, + $case_sensitive_column + ); + $params = array( $table_schema, $table_name ); + if ( null !== $column_name ) { + $params[] = $column_name; + } + + $stmt = $this->connection->query( $sql, $params ); + return $this->normalize_mysql_table_catalog_column_metadata_rows( $stmt->fetchAll( PDO::FETCH_ASSOC ) ); + } + private function filter_mysql_table_catalog_column_metadata_rows( + array $rows, + ?string $column_name, + bool $case_sensitive_column + ): array { + if ( null === $column_name ) { + return $rows; + } + + $filtered = array(); + foreach ( $rows as $row ) { + $name = (string) ( $row['column_name'] ?? '' ); + if ( $case_sensitive_column ? $name === $column_name : 0 === strcasecmp( $name, $column_name ) ) { + $filtered[] = $row; + if ( 2 === count( $filtered ) ) { + break; + } + } + } + return $filtered; + } + private function normalize_mysql_table_catalog_column_metadata_rows( array $rows ): array { + foreach ( $rows as &$row ) { + if ( ! array_key_exists( 'column_type', $row ) && array_key_exists( 'mysql_column_type', $row ) ) { + $row['column_type'] = $row['mysql_column_type']; + } + if ( ! array_key_exists( 'extra', $row ) && array_key_exists( 'mysql_extra', $row ) ) { + $row['extra'] = $row['mysql_extra']; + } + if ( ! array_key_exists( 'ordinal_position', $row ) ) { + $row['ordinal_position'] = count( $rows ); + } + } + unset( $row ); + return $rows; + } + private function get_mysql_table_catalog_column_metadata_row( + string $table_schema, + string $table_name, + string $column_name + ): ?array { + $rows = $this->get_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name, $column_name ); + return 1 === count( $rows ) ? $rows[0] : null; + } + private function get_cached_mysql_table_catalog_column_metadata_row( + string $table_schema, + string $table_name, + string $column_name + ): ?array { + $rows = $this->get_cached_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name, $column_name ); + return 1 === count( $rows ) ? $rows[0] : null; + } + private function get_postgresql_catalog_column_metadata_sql( + string $projection_sql, + bool $filter_column, + bool $case_sensitive_column = false + ): string { + $column_filter_sql = ''; + if ( $filter_column ) { + $column_filter_sql = $case_sensitive_column + ? "\n\t\t\t\t\tAND c.column_name = ?" + : "\n\t\t\t\t\tAND LOWER(c.column_name) = LOWER(?)"; + } + return sprintf( + 'WITH catalog_columns AS MATERIALIZED ( + SELECT c.*, + pg_catalog.col_description(pc.oid, pa.attnum) AS column_comment, + pg_catalog.obj_description( + pg_catalog.pg_get_serial_sequence(format(\'%%I.%%I\', c.table_schema, c.table_name), c.column_name)::regclass, + \'pg_class\' + ) AS identity_sequence_comment + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_namespace pn + ON pn.nspname = c.table_schema + LEFT JOIN pg_catalog.pg_class pc + ON pc.relnamespace = pn.oid + AND pc.relname = c.table_name + AND pc.relkind IN (\'r\', \'p\', \'v\', \'m\') + LEFT JOIN pg_catalog.pg_attribute pa + ON pa.attrelid = pc.oid + AND pa.attname = c.column_name + AND pa.attnum > 0 + WHERE c.table_schema = ? + AND c.table_name = ?%2$s + ) + SELECT %1$s + FROM catalog_columns c + ORDER BY c.ordinal_position%3$s', + $projection_sql, + $column_filter_sql, + $filter_column ? "\n\t\t\t\tLIMIT 2" : '' + ); + } + private function mysql_table_has_column_metadata( string $table_schema, string $table_name ): bool { + return array() !== $this->get_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name ); + } + private function is_create_table_query( string $query ): bool { + return null !== $this->get_mysql_create_table_prefix( $this->get_mysql_tokens( $query ) ); + } + private function get_mysql_create_table_target( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $prefix = $this->get_mysql_create_table_prefix( $tokens ); + if ( null === $prefix ) { + return null; + } + + $position = $prefix['position']; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( + null === $table_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $position, $prefix['statement_end'] ) + ) { + return null; + } + + return array( + 'schema' => $this->get_mysql_create_table_select_backend_schema( $table_reference, $prefix['temporary'] ), + 'table' => $table_reference['table'], + 'temporary' => $prefix['temporary'], + ); + } + private function mysql_create_table_if_not_exists_target_exists( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + $prefix = $this->get_mysql_create_table_prefix( $tokens ); + if ( null === $prefix || ! $prefix['if_not_exists'] ) { + return false; + } + + $position = $prefix['position']; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( + null === $table_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $position, $prefix['statement_end'] ) + ) { + return false; + } + + return $this->mysql_create_table_target_exists( + $this->get_mysql_create_table_select_backend_schema( $table_reference, $prefix['temporary'] ), + $table_reference['table'], + $prefix['temporary'] + ); + } + private function translate_mysql_create_table_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $prefix = $this->get_mysql_create_table_prefix( $tokens ); + if ( null === $prefix ) { + return null; + } + $statement_end = $prefix['statement_end']; + $position = $prefix['position']; + $is_temporary = $prefix['temporary']; + $if_not_exists = $prefix['if_not_exists']; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( + null === $table_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $position, $statement_end ) + ) { + return null; + } + $definition_start = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $parenthesized_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + if ( null === $this->get_mysql_create_table_select_range( $tokens, $position, $statement_end ) ) { + $definition_start = $position; + $position = $parenthesized_end; + } + } + $table_comment = ''; + if ( ! $this->consume_mysql_create_table_select_options( $tokens, $position, $statement_end, $table_comment ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + $metadata_end = $position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + $select_range = $this->get_mysql_create_table_select_range( $tokens, $position, $statement_end ); + if ( null === $select_range ) { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $definition_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null !== $definition_end && isset( $tokens[ $definition_end ] ) ) { + $after_definition = $definition_end; + if ( WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $after_definition ]->id ) { + ++$after_definition; + } + if ( isset( $tokens[ $after_definition ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $after_definition ]->id ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + } + } + if ( null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SELECT_SYMBOL, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + return null; + } + $schema_name = $this->get_mysql_create_table_select_backend_schema( $table_reference, $is_temporary ); + if ( $if_not_exists && $this->mysql_create_table_target_exists( $schema_name, $table_reference['table'], $is_temporary ) ) { + return $this->get_mysql_create_table_translation( array(), $schema_name, $table_reference['table'], $is_temporary, null, true, array( 'table_comment' => $table_comment ) ); + } + $select_sql = $this->get_mysql_token_range_bytes( $query, $tokens, $select_range[0], $select_range[1] ); + if ( '' === trim( $select_sql ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + $select_translation = $this->translate_mysql_select_query_for_postgresql( $select_sql ); + $table_identifier = $is_temporary ? $this->connection->quote_identifier( $table_reference['table'] ) : $this->get_postgresql_schema_identifier( $schema_name, $table_reference['table'] ); + if ( null !== $definition_start ) { + $metadata_query = trim( $this->get_mysql_token_range_bytes( $query, $tokens, 0, $metadata_end ) ); + if ( '' === $metadata_query ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $metadata_tables = $translator->extract_schema_metadata( $metadata_query, true ); + if ( 1 !== count( $metadata_tables ) || empty( $metadata_tables[0]['columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + $column_identifiers = array_map( array( $this->connection, 'quote_identifier' ), array_map( 'strval', array_column( $metadata_tables[0]['columns'], 'name' ) ) ); + $statements = $this->qualify_translated_create_table_statements( + $translator->translate_schema( $metadata_query ), + $schema_name, + $table_reference['table'], + $is_temporary + ); + $statements[] = sprintf( + 'INSERT INTO %s (%s) %s', + $table_identifier, + implode( ', ', $column_identifiers ), + $select_translation['sql'] + ); + return $this->get_mysql_create_table_translation( $statements, $schema_name, $table_reference['table'], $is_temporary, $metadata_query, false, array( 'table_comment' => $table_comment ) ); + } + return $this->get_mysql_create_table_translation( + array( sprintf( 'CREATE %sTABLE %s%s AS %s', $is_temporary ? 'TEMPORARY ' : '', $if_not_exists ? 'IF NOT EXISTS ' : '', $table_identifier, $select_translation['sql'] ) ), + $schema_name, + $table_reference['table'], + $is_temporary, + null, + false, + array( 'table_comment' => $table_comment ) + ); + } + private function get_mysql_create_table_select_range( array $tokens, int $position, int $statement_end ): ?array { + if ( WP_MySQL_Lexer::SELECT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + return array( $position, $statement_end ); + } + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + return null !== $parenthesized_end + && $parenthesized_end === $statement_end + && WP_MySQL_Lexer::SELECT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + ? array( $position + 1, $parenthesized_end - 1 ) + : null; + } + private function get_mysql_create_table_translation( array $statements, string $schema_name, string $table_name, bool $temporary, ?string $metadata_query, bool $noop, array $extra = array() ): array { + return array_merge( + array( + 'statements' => $statements, + 'schema' => $schema_name, + 'table' => $table_name, + 'temporary' => $temporary, + 'metadata_query' => $metadata_query, + 'noop' => $noop, + ), + $extra + ); + } + private function consume_mysql_create_table_select_options( array $tokens, int &$position, int $statement_end, string &$table_comment = '' ): bool { + $assignment_option_ids = array( WP_MySQL_Lexer::AUTOEXTEND_SIZE_SYMBOL, WP_MySQL_Lexer::AVG_ROW_LENGTH_SYMBOL, WP_MySQL_Lexer::CHECKSUM_SYMBOL, WP_MySQL_Lexer::COMPRESSION_SYMBOL, WP_MySQL_Lexer::CONNECTION_SYMBOL, WP_MySQL_Lexer::DELAY_KEY_WRITE_SYMBOL, WP_MySQL_Lexer::ENCRYPTION_SYMBOL, WP_MySQL_Lexer::ENGINE_SYMBOL, WP_MySQL_Lexer::ENGINE_ATTRIBUTE_SYMBOL, WP_MySQL_Lexer::INSERT_METHOD_SYMBOL, WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL, WP_MySQL_Lexer::MAX_ROWS_SYMBOL, WP_MySQL_Lexer::MIN_ROWS_SYMBOL, WP_MySQL_Lexer::PACK_KEYS_SYMBOL, WP_MySQL_Lexer::PASSWORD_SYMBOL, WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, WP_MySQL_Lexer::SECONDARY_ENGINE_SYMBOL, WP_MySQL_Lexer::SECONDARY_ENGINE_ATTRIBUTE_SYMBOL, WP_MySQL_Lexer::STATS_AUTO_RECALC_SYMBOL, WP_MySQL_Lexer::STATS_PERSISTENT_SYMBOL, WP_MySQL_Lexer::STATS_SAMPLE_PAGES_SYMBOL ); + $value_boundary_ids = array_merge( $assignment_option_ids, array( WP_MySQL_Lexer::AS_SYMBOL, WP_MySQL_Lexer::CHARACTER_SYMBOL, WP_MySQL_Lexer::CHARSET_SYMBOL, WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::COLLATE_SYMBOL, WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::COMMENT_SYMBOL, WP_MySQL_Lexer::DATA_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::SEMICOLON_SYMBOL, WP_MySQL_Lexer::TABLESPACE_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL ) ); + $option_descriptors = array( array( array( WP_MySQL_Lexer::CHARSET_SYMBOL ), 'charset', true ), array( array( WP_MySQL_Lexer::COLLATE_SYMBOL ), 'charset', true ), array( array( WP_MySQL_Lexer::CHARACTER_SYMBOL, WP_MySQL_Lexer::SET_SYMBOL ), 'charset', true ), array( array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::SET_SYMBOL ), 'charset', true ), array( array( WP_MySQL_Lexer::DATA_SYMBOL, WP_MySQL_Lexer::DIRECTORY_SYMBOL ), 'value', false ), array( array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::DIRECTORY_SYMBOL ), 'value', false ), array( array( WP_MySQL_Lexer::TABLESPACE_SYMBOL ), 'tablespace', false ), array( array( WP_MySQL_Lexer::UNION_SYMBOL ), 'union', false ) ); + foreach ( $assignment_option_ids as $assignment_option_id ) { + $option_descriptors[] = array( array( $assignment_option_id ), 'value', false ); + } + $consume_value = function ( int $value_position, bool $allow_equal = true ) use ( $tokens, $statement_end, $value_boundary_ids ): ?int { + if ( $allow_equal && WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $value_position ]->id ?? null ) ) { + ++$value_position; + } + if ( ! isset( $tokens[ $value_position ] ) || $value_position >= $statement_end || in_array( $tokens[ $value_position ]->id, $value_boundary_ids, true ) ) { + return null; + } + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $value_position ]->id + ? $this->get_mysql_parenthesized_sequence_end( $tokens, $value_position, $statement_end ) + : $value_position + 1; + }; + while ( $position < $statement_end && isset( $tokens[ $position ] ) ) { + $token_id = $tokens[ $position ]->id; + if ( in_array( $token_id, array( WP_MySQL_Lexer::AS_SYMBOL, WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::SELECT_SYMBOL ), true ) ) { + return true; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $token_id ) { + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::COMMENT_SYMBOL === $token_id ) { + $comment_token = $this->consume_mysql_index_option_value( $tokens, $position, $statement_end, true, 'quoted' ); + if ( null === $comment_token ) { + return false; + } + $table_comment = $comment_token->get_value(); + continue; + } + + foreach ( $option_descriptors as list( $option_sequence, $option_type, $allow_default ) ) { + $option_position = $allow_default && WP_MySQL_Lexer::DEFAULT_SYMBOL === $token_id ? $position + 1 : $position; + $after_option = $this->consume_mysql_table_administration_token_sequence( $tokens, $option_position, $option_sequence ); + if ( null === $after_option || ( WP_MySQL_Lexer::CHAR_SYMBOL === $option_sequence[0] && ! in_array( strtolower( $tokens[ $option_position ]->get_bytes() ), array( 'char', 'character' ), true ) ) ) { + continue; + } + if ( 'charset' === $option_type ) { + $position = $after_option; + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + if ( ! isset( $tokens[ $position ] ) || $position >= $statement_end || ! $this->is_mysql_charset_token( $tokens[ $position ] ) ) { + return false; + } + ++$position; + } elseif ( 'union' === $option_type ) { + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $after_option ]->id ?? null ) ) { + ++$after_option; + } + $position = $this->get_mysql_parenthesized_sequence_end( $tokens, $after_option, $statement_end ); + } else { + $position = $consume_value( $after_option, 'tablespace' !== $option_type ); + if ( isset( $tokens[ $position ] ) && 'tablespace' === $option_type && WP_MySQL_Lexer::STORAGE_SYMBOL === $tokens[ $position ]->id ) { + $position = $consume_value( $position + 1, false ); + } + } + if ( null === $position ) { + return false; + } + continue 2; + } + return true; + } + return true; + } + private function get_mysql_create_table_select_backend_schema( array $table_reference, bool $is_temporary ): string { + $requested_schema = $table_reference['schema']; + if ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( $is_temporary ) { + if ( null !== $requested_schema && 0 !== strcasecmp( $requested_schema, $this->main_db_name ) && 0 !== strcasecmp( $requested_schema, 'public' ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + return $this->get_temporary_drop_table_schema_name(); + } + + $uses_current_schema = null === $requested_schema; + $backend_schema = $uses_current_schema ? $this->db_name : $requested_schema; + if ( $uses_current_schema && 0 === strcasecmp( $backend_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + if ( 0 === strcasecmp( $backend_schema, $this->main_db_name ) || 0 === strcasecmp( $backend_schema, 'public' ) ) { + return 'public'; + } + + if ( ! $this->is_postgresql_internal_schema( $backend_schema ) ) { + return $backend_schema; + } + if ( $uses_current_schema ) { + return 'public'; + } + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + private function qualify_translated_create_table_statements( array $statements, string $schema_name, string $table_name, bool $is_temporary ): array { + if ( $is_temporary || 'public' === $schema_name ) { + return $statements; + } + + $quoted_table = $this->connection->quote_identifier( $table_name ); + $table_sql = $this->get_postgresql_schema_identifier( $schema_name, $table_name ); + + foreach ( $statements as $index => $statement ) { + $statement = (string) preg_replace( + '/^(CREATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)' . preg_quote( $quoted_table, '/' ) . '(\s*\()/i', + '$1' . $table_sql . '$2', + $statement, + 1 + ); + + $statements[ $index ] = (string) preg_replace_callback( + '/^(CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?)(\"(?:[^\"]|\"\")+\")\s+ON\s+' . preg_quote( $quoted_table, '/' ) . '(\s*\()/i', + static function ( array $matches ) use ( $table_sql ): string { + return $matches[1] . $matches[2] . ' ON ' . $table_sql . $matches[3]; + }, + $statement, + 1 + ); + } + return $statements; + } + private function translate_mysql_create_table_like_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $prefix = $this->get_mysql_create_table_prefix( $tokens ); + if ( null === $prefix ) { + return null; + } + + $references = $this->get_mysql_create_table_like_references( $tokens, $prefix['position'], $prefix['statement_end'] ); + if ( null === $references ) { + return null; + } + $target_reference = $references['target']; + $target_schema = $this->get_mysql_create_table_select_backend_schema( $target_reference, $prefix['temporary'] ); + if ( $prefix['if_not_exists'] && $this->mysql_create_table_target_exists( $target_schema, $target_reference['table'], $prefix['temporary'] ) ) { + return $this->get_mysql_create_table_translation( array(), $target_schema, $target_reference['table'], $prefix['temporary'], '', true ); + } + return $this->get_mysql_create_table_like_translation( $target_reference, $references['source'], $target_schema, $prefix['temporary'], $prefix['if_not_exists'] ); + } + private function get_mysql_create_table_like_references( array $tokens, int $position, int $statement_end ): ?array { + $target_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( + null === $target_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $position, $statement_end ) + ) { + return null; + } + + $parenthesized_like_end = null; + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $parenthesized_like_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $parenthesized_like_end || $parenthesized_like_end !== $statement_end || WP_MySQL_Lexer::LIKE_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) { + return null; + } + ++$position; + } elseif ( WP_MySQL_Lexer::LIKE_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + ++$position; + $source_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $source_reference || ( null === $parenthesized_like_end ? $statement_end : $parenthesized_like_end - 1 ) !== $position ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + return array( + 'target' => $target_reference, + 'source' => $source_reference, + ); + } + private function is_mysql_create_table_target_boundary( array $tokens, int $position, int $statement_end ): bool { + if ( $position >= $statement_end || ! isset( $tokens[ $position ] ) ) { + return true; + } + + if ( WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) { + return false; + } + + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::AS_SYMBOL, WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::EOF, WP_MySQL_Lexer::SEMICOLON_SYMBOL ), true ) ) { + return true; + } + + $option_position = $position; + $table_comment = ''; + return $this->consume_mysql_create_table_select_options( $tokens, $option_position, $statement_end, $table_comment ) + && $option_position !== $position; + } + private function get_mysql_create_table_like_translation( array $target_reference, array $source_reference, string $target_schema, bool $is_temporary, bool $if_not_exists ): array { + $source_schema = $this->get_mysql_read_table_backend_schema( $source_reference['schema'] ); + if ( 0 === strcasecmp( $source_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $source_schema = $this->resolve_mysql_table_schema_for_introspection( $source_schema, $source_reference['table'] ); + $metadata = $this->get_show_create_table_metadata( $source_schema, $source_reference['table'], false, false ); + if ( empty( $metadata['columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $metadata_query = $this->get_mysql_create_table_statement_from_metadata( $target_reference['table'], $metadata['columns'], $metadata['indexes'], array(), $metadata['checks'], $metadata['table']['comment'], $is_temporary, $metadata['table']['collation'] ); + if ( $if_not_exists ) { + $prefix = $is_temporary ? 'CREATE TEMPORARY TABLE ' : 'CREATE TABLE '; + $replacement = $is_temporary ? 'CREATE TEMPORARY TABLE IF NOT EXISTS ' : 'CREATE TABLE IF NOT EXISTS '; + $metadata_query = 0 === strpos( $metadata_query, $prefix ) ? $replacement . substr( $metadata_query, strlen( $prefix ) ) : $metadata_query; + } + $statements = $this->qualify_translated_create_table_statements( ( new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ) )->translate_schema( $metadata_query ), $target_schema, $target_reference['table'], $is_temporary ); + return $this->get_mysql_create_table_translation( $statements, $target_schema, $target_reference['table'], $is_temporary, $metadata_query, false ); + } + private function get_mysql_create_table_prefix( array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $is_temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $is_temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + ++$position; + $if_not_exists = $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + return array( + 'statement_end' => $statement_end, + 'position' => $position, + 'temporary' => $is_temporary, + 'if_not_exists' => $if_not_exists, + ); + } + private function consume_mysql_if_not_exists_sequence( 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 true; + } + return false; + } + private function consume_mysql_if_exists_sequence( array $tokens, int &$position ): bool { + if ( isset( $tokens[ $position ], $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return true; + } + return false; + } + private function is_mysql_spatial_column_type( string $column_type ): bool { + $column_type = preg_replace( '/[\s(].*$/', '', strtolower( trim( $column_type ) ) ); + return in_array( $column_type, self::MYSQL_SPATIAL_COLUMN_TYPES, true ); + } + 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; + } + + $position = 1; + $prefix = $this->get_mysql_create_index_prefix( $tokens, $position ); + if ( null === $prefix ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $is_unique = $prefix[0]; + $index_type = $prefix[1]; + $if_not_exists = $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + + $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_schema_aware_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 ); + if ( null === $key_parts ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + if ( 'BTREE' === $index_type && isset( $key_parts['metadata'][0]['column_name'] ) ) { + $column_type = $this->get_mysql_table_column_type( $table_schema, $table_reference['table'], (string) $key_parts['metadata'][0]['column_name'] ); + if ( is_string( $column_type ) && $this->is_mysql_spatial_column_type( $column_type ) ) { + $index_type = 'SPATIAL'; + } + } + if ( 'SPATIAL' === $index_type ) { + foreach ( $key_parts['metadata'] as $part_position => $key_part ) { + if ( null === $key_part['sub_part'] ) { + $key_parts['metadata'][ $part_position ]['sub_part'] = $this->get_mysql_metadata_only_index_sql_sub_part( $index_type, null ); + } + } + } + + $position = $key_list_end; + $index_comment = ''; + if ( ! $this->consume_mysql_supported_create_index_options( $tokens, $position, $statement_end, $index_type, $index_comment ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $table_name = $table_reference['table']; + $metadata_index_name = $index_name; + $postgresql_index_name = $table_name . '__' . $index_name; + if ( + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $index_name_token->id + && $table_reference_is_quoted + && 0 === strpos( $index_name, $table_name . '__' ) + ) { + $metadata_index_name = substr( $index_name, strlen( $table_name ) + 2 ); + $postgresql_index_name = $index_name; + if ( '' === $metadata_index_name ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + } + + $postgresql_table = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $index = $this->get_mysql_key_value_array( 'name', $metadata_index_name, 'non_unique', $is_unique ? '0' : '1', 'index_type', $index_type, 'comment', $index_comment, 'columns', $key_parts['metadata'] ); + $statement = $this->get_postgresql_mysql_index_create_statement( $table_name, $index, $postgresql_table, true, $table_schema, $if_not_exists, $postgresql_index_name ); + $statements = null === $statement ? array() : array( $statement ); + return $this->get_mysql_ddl_translation( + $statements, + $this->get_mysql_key_value_array( + 'schema', + $table_schema, + 'table', + $table_name, + 'index', + $index + ) + ); + } + private function is_mysql_metadata_only_index_type( string $index_type ): bool { + return in_array( strtoupper( $index_type ), array( 'FULLTEXT', 'SPATIAL' ), true ); + } + private function get_mysql_create_index_prefix( array $tokens, int &$position ): ?array { + $is_unique = false; + if ( WP_MySQL_Lexer::UNIQUE_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $is_unique = true; + ++$position; + } + + $metadata_only_types = array_combine( array( WP_MySQL_Lexer::FULLTEXT_SYMBOL, WP_MySQL_Lexer::SPATIAL_SYMBOL ), array( 'FULLTEXT', 'SPATIAL' ) ); + $index_type = $metadata_only_types[ $tokens[ $position ]->id ?? null ] ?? 'BTREE'; + if ( 'BTREE' !== $index_type ) { + if ( $is_unique ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + ++$position; + } + + if ( WP_MySQL_Lexer::INDEX_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + + ++$position; + return array( $is_unique, $index_type ); + } + private function translate_mysql_view_query( string $query, int $statement_id, string $statement_type ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || $statement_id !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + for ( $i = 1; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $i ]->id ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + } + return null; + } + + $position = 1; + $or_replace = false; + if ( ! $this->consume_mysql_view_statement_prefix( $tokens, $position, $statement_end, $statement_id, $statement_type, $or_replace ) ) { + return null; + } + return $this->translate_mysql_view_definition_query( $query, $tokens, $position, $statement_end, $or_replace, $statement_type ); + } + private function consume_mysql_view_statement_prefix( array $tokens, int &$position, int $statement_end, int $statement_id, string $statement_type, bool &$or_replace ): bool { + $or_replace = WP_MySQL_Lexer::ALTER_SYMBOL === $statement_id; + if ( WP_MySQL_Lexer::CREATE_SYMBOL === $statement_id && WP_MySQL_Lexer::OR_SYMBOL === ( $tokens[ $position ]->id ?? null ) && WP_MySQL_Lexer::REPLACE_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $or_replace = true; + $position += 2; + } + if ( $this->contains_mysql_unsupported_view_prefix_clause( $tokens, $position, $statement_end ) ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + if ( WP_MySQL_Lexer::VIEW_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + return true; + } + private function translate_mysql_view_definition_query( string $query, array $tokens, int $position, int $statement_end, bool $or_replace, string $statement_type ): array { + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $view_reference ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + + $view_schema = $this->get_mysql_schema_aware_table_backend_schema( $view_reference, $statement_type ); + $view_identifier = $this->get_mysql_schema_aware_table_identifier( $view_reference, $view_schema ); + $columns_sql = $this->get_postgresql_mysql_view_column_list_sql( $tokens, $position, $statement_end, $statement_type ); + + if ( WP_MySQL_Lexer::AS_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + + ++$position; + if ( WP_MySQL_Lexer::SELECT_SYMBOL !== ( $tokens[ $position ]->id ?? null ) || false !== $this->contains_unsupported_mysql_view_select_suffix_clause( $tokens, $position, $statement_end ) ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + + $select_sql = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $statement_end ); + $this->validate_mysql_view_select_query( $select_sql, $statement_type ); + $select_translation = $this->translate_mysql_select_query_for_postgresql( $select_sql ); + $select_sql = $select_translation['sql']; + $this->validate_mysql_view_select_query( $select_sql, $statement_type ); + return array( 'statements' => array( sprintf( 'CREATE %sVIEW %s%s AS %s', $or_replace ? 'OR REPLACE ' : '', $view_identifier, $columns_sql, $select_sql ) ) ); + } + private function throw_unsupported_mysql_view_statement( string $statement_type ): void { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + private function get_postgresql_mysql_view_column_list_sql( array $tokens, int &$position, int $statement_end, string $statement_type ): string { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return ''; + } + $columns_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + $column_ranges = null === $columns_end ? null : $this->split_top_level_mysql_arguments( $tokens, $position + 1, $columns_end - 1 ); + if ( empty( $column_ranges ) ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + + $columns = array(); + foreach ( $column_ranges as $range ) { + $column_name = $this->get_mysql_identifier_token_value( $tokens[ $range['start'] ] ?? null ); + if ( $range['start'] + 1 !== $range['end'] || null === $column_name ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + $columns[] = $this->connection->quote_identifier( $column_name ); + } + $position = $columns_end; + return ' (' . implode( ', ', $columns ) . ')'; + } + private function contains_unsupported_mysql_view_select_suffix_clause( array $tokens, int $position, int $statement_end ): ?bool { + $depth = 0; + for ( $i = $position; $i < $statement_end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + } elseif ( 0 === $depth && WP_MySQL_Lexer::WITH_SYMBOL === $tokens[ $i ]->id && in_array( $tokens[ $i + 1 ]->id ?? null, array( WP_MySQL_Lexer::CASCADED_SYMBOL, WP_MySQL_Lexer::CHECK_SYMBOL, WP_MySQL_Lexer::LOCAL_SYMBOL ), true ) ) { + return true; + } + } + return 0 === $depth ? false : null; + } + private function contains_mysql_unsupported_view_prefix_clause( array $tokens, int $position, int $statement_end ): bool { + $unsupported = false; + for ( $i = $position; $i < $statement_end; $i++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $i ]->id ) { + return $unsupported; + } + $unsupported = $unsupported || in_array( $tokens[ $i ]->id, array( WP_MySQL_Lexer::ALGORITHM_SYMBOL, WP_MySQL_Lexer::DEFINER_SYMBOL, WP_MySQL_Lexer::SECURITY_SYMBOL ), true ) + || ( WP_MySQL_Lexer::SQL_SYMBOL === $tokens[ $i ]->id && WP_MySQL_Lexer::SECURITY_SYMBOL === ( $tokens[ $i + 1 ]->id ?? null ) ); + } + return false; + } + private function validate_mysql_view_select_query( string $select_sql, string $statement_type ): void { + foreach ( self::MYSQL_VIEW_SELECT_VALIDATION_SCANNERS as $scanner ) { + if ( $this->{$scanner[0]}( $select_sql, ...( $scanner[1] ?? array() ) ) ) { + $this->throw_unsupported_mysql_view_statement( $statement_type ); + } + } + } + private function consume_mysql_supported_create_index_type( array $tokens, int &$position ): bool { + if ( WP_MySQL_Lexer::USING_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return true; + } + return null !== $this->consume_mysql_index_option_value( $tokens, $position, count( $tokens ), false, 'values', array( 'btree', 'hash' ) ); + } + private function parse_mysql_create_index_key_parts( array $tokens, int $start, int $end ): ?array { + $key_part_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $key_part_ranges || array() === $key_part_ranges ) { + return null; + } + + $metadata_parts = array(); + foreach ( $key_part_ranges as $key_part_range ) { + $position = $key_part_range['start']; + $column_name = $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null, true ); + if ( null === $column_name ) { + return null; + } + + ++$position; + $sub_part = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + $sub_part = $tokens[ $position + 1 ]->get_value(); + $position += 3; + } + + $direction = ''; + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::ASC_SYMBOL, WP_MySQL_Lexer::DESC_SYMBOL ), true ) + ) { + $direction = ' ' . strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + } + + if ( $position !== $key_part_range['end'] ) { + return null; + } + + $metadata_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $metadata_parts ) + 1, + 'collation' => ' DESC' === $direction ? 'D' : 'A', + 'sub_part' => $sub_part, + ); + } + return array( + 'metadata' => $metadata_parts, + ); + } + 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; + } + private function get_mysql_index_key_part_sql( string $column_name, $sub_part ): string { + if ( null !== $sub_part && '' !== (string) $sub_part ) { + return sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $this->connection->quote_identifier( $column_name ), + (int) $sub_part + ); + } + return $this->connection->quote_identifier( $column_name ); + } + private function get_postgresql_mysql_index_create_statement( string $table_name, array $index, string $postgresql_table, bool $use_sub_parts = true, ?string $table_schema = null, bool $if_not_exists = false, ?string $postgresql_index_name = null ): ?string { + $index_name = (string) ( $index['name'] ?? '' ); + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + $column_list = $this->get_mysql_index_column_list_sql( $index['columns'] ?? array(), $use_sub_parts ); + return null === $column_list ? null : sprintf( 'ALTER TABLE %s ADD PRIMARY KEY (%s)', $postgresql_table, $column_list ); + } + + $index_type = (string) ( $index['index_type'] ?? 'BTREE' ); + if ( $this->is_mysql_metadata_only_index_type( $index_type ) ) { + return $this->get_postgresql_catalog_metadata_only_index_create_statement( $table_schema, $table_name, $index, $if_not_exists ); + } + + $column_list = $this->get_mysql_index_column_list_sql( $index['columns'] ?? array(), $use_sub_parts ); + if ( null === $column_list ) { + return null; + } + $is_unique = '0' === (string) ( $index['non_unique'] ?? '1' ); + return $this->get_postgresql_backend_create_index_statement( + $this->connection->quote_identifier( $postgresql_index_name ?? $table_name . '__' . $index_name ), + $postgresql_table, + $column_list, + $is_unique, + $if_not_exists + ); + } + private function get_postgresql_backend_create_index_statement( string $postgresql_index, string $postgresql_table, string $column_list, bool $is_unique = false, bool $if_not_exists = false ): string { + return sprintf( 'CREATE %sINDEX %s%s ON %s (%s)', $is_unique ? 'UNIQUE ' : '', $if_not_exists ? 'IF NOT EXISTS ' : '', $postgresql_index, $postgresql_table, $column_list ); + } + private function get_mysql_index_column_list_sql( array $columns, bool $use_sub_parts = true, ?string $metadata_only_index_type = null ): ?string { + $metadata_only_index_type = null === $metadata_only_index_type ? null : strtoupper( $metadata_only_index_type ); + $sql_parts = array(); + foreach ( $columns as $column ) { + $sub_part = null === $metadata_only_index_type && $use_sub_parts ? $column['sub_part'] ?? null : null; + $sub_part = null === $metadata_only_index_type ? $sub_part : $this->get_mysql_metadata_only_index_sql_sub_part( $metadata_only_index_type, $column['sub_part'] ?? null ); + $column_sql = $this->get_mysql_index_key_part_sql( (string) ( $column['column_name'] ?? '' ), $sub_part ); + $sql_parts[] = 'FULLTEXT' !== $metadata_only_index_type && 'D' === strtoupper( (string) ( $column['collation'] ?? '' ) ) ? $column_sql . ' DESC' : $column_sql; + } + return array() === $sql_parts ? null : implode( ', ', $sql_parts ); + } + private function get_postgresql_catalog_metadata_only_index_create_statement( + ?string $table_schema, + string $table_name, + array $index, + bool $if_not_exists = false + ): ?string { + $cannot_create_metadata_only_index = 'PRIMARY' === strtoupper( (string) ( $index['name'] ?? '' ) ) + || ! $this->is_mysql_metadata_only_index_type( (string) ( $index['index_type'] ?? 'BTREE' ) ); + if ( $cannot_create_metadata_only_index ) { + return null; + } + + $index_type = strtoupper( (string) $index['index_type'] ); + $column_list = $this->get_mysql_index_column_list_sql( $index['columns'] ?? array(), false, $index_type ); + if ( null === $column_list ) { + return null; + } + + $postgresql_table = null === $table_schema + ? $this->connection->quote_identifier( $table_name ) + : $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + return $this->get_postgresql_backend_create_index_statement( $this->connection->quote_identifier( $table_name . '__' . $index['name'] ), $postgresql_table, $column_list, false, $if_not_exists ); + } + private function consume_mysql_supported_create_index_options( array $tokens, int &$position, int $statement_end, string $index_type, string &$index_comment ): bool { + if ( $this->is_mysql_metadata_only_index_type( $index_type ) ) { + return $position === $statement_end; + } + + while ( $position < $statement_end ) { + $comment_token = WP_MySQL_Lexer::COMMENT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ? $this->consume_mysql_index_option_value( $tokens, $position, $statement_end, false, 'quoted' ) + : null; + if ( null !== $comment_token ) { + $index_comment = $comment_token->get_value(); + continue; + } + + $before_type_position = $position; + if ( ! $this->consume_mysql_supported_create_index_type( $tokens, $position ) ) { + return false; + } + if ( $position !== $before_type_position ) { + continue; + } + + $token_id = $tokens[ $position ]->id ?? null; + if ( in_array( $token_id, array( WP_MySQL_Lexer::VISIBLE_SYMBOL, WP_MySQL_Lexer::INVISIBLE_SYMBOL ), true ) ) { + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL === $token_id && null !== $this->consume_mysql_index_option_value( $tokens, $position, $statement_end, true, 'unsigned' ) ) { + continue; + } + + if ( $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $position, $statement_end ) ) { + continue; + } + return false; + } + return true; + } + private function consume_mysql_supported_index_lock_and_algorithm_options( array $tokens, int &$position, int $statement_end ): bool { + $options = array_combine( array( WP_MySQL_Lexer::ALGORITHM_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL ), array( array( 'default', 'inplace', 'copy' ), array( 'default', 'none', 'shared', 'exclusive' ) ) ); + $token_id = $tokens[ $position ]->id ?? null; + if ( ! isset( $options[ $token_id ] ) ) { + return false; + } + return null !== $this->consume_mysql_index_option_value( $tokens, $position, $statement_end, true, 'values', $options[ $token_id ] ); + } + private function get_mysql_metadata_only_index_sql_sub_part( string $index_type, $sub_part ) { + if ( 'FULLTEXT' === $index_type ) { + return 191; + } + return 'SPATIAL' === $index_type && ( null === $sub_part || '' === (string) $sub_part ) ? 32 : $sub_part; + } + private function consume_mysql_index_option_value( array $tokens, int &$position, int $statement_end, bool $allow_equals, string $value_type, array $supported_values = array() ): ?WP_MySQL_Token { + $value_position = $position + 1; + if ( $allow_equals && WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $value_position ]->id ?? null ) ) { + ++$value_position; + } + if ( $value_position >= $statement_end || ! isset( $tokens[ $value_position ] ) ) { + return null; + } + + $value_token = $tokens[ $value_position ]; + if ( ( 'quoted' === $value_type && ! $this->is_mysql_string_literal_token( $value_token ) ) || ( 'unsigned' === $value_type && ! $this->is_mysql_unsigned_integer_token( $value_token ) ) ) { + return null; + } + if ( 'values' === $value_type ) { + foreach ( $supported_values as $supported_value ) { + if ( $this->is_mysql_token_value( $value_token, $supported_value ) ) { + $position = $value_position + 1; + return $value_token; + } + } + return null; + } + + $position = $value_position + 1; + return $value_token; + } + private function get_mysql_schema_aware_table_backend_schema( array $table_reference, string $statement_type ): string { + $requested_schema = $table_reference['schema']; + + if ( null !== $requested_schema ) { + return $this->get_mysql_explicit_table_backend_schema( $requested_schema, $statement_type, true ); + } + + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_reference['table'] ); + if ( 'public' !== $resolved_schema ) { + return $resolved_schema; + } + + $uses_current_catalog_schema = 0 !== strcasecmp( $this->db_name, $this->main_db_name ) + && 0 !== strcasecmp( $this->db_name, 'public' ) + && ! $this->is_postgresql_internal_schema( $this->db_name ); + if ( $uses_current_catalog_schema ) { + return $this->db_name; + } + return $resolved_schema; + } + 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.' ); + } + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + if ( 'public' !== $resolved_schema ) { + return $resolved_schema; + } + $uses_current_catalog_schema = 0 !== strcasecmp( $this->db_name, $this->main_db_name ) + && 0 !== strcasecmp( $this->db_name, 'public' ) + && ! $this->is_postgresql_internal_schema( $this->db_name ); + if ( $uses_current_catalog_schema ) { + return $this->db_name; + } + return $resolved_schema; + } + return $this->get_mysql_explicit_table_backend_schema( $requested_schema, $statement_type, false ); + } + private function get_mysql_explicit_table_backend_schema( string $requested_schema, string $statement_type, bool $allow_catalog_schema ): string { + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $is_main_schema = 0 === strcasecmp( $requested_schema, $this->main_db_name ) || 0 === strcasecmp( $requested_schema, 'public' ); + if ( $is_main_schema ) { + return 'public'; + } + + $allows_requested_catalog_schema = $allow_catalog_schema + && ! $this->is_postgresql_internal_schema( $requested_schema ); + if ( $allows_requested_catalog_schema ) { + return $requested_schema; + } + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + private function get_mysql_read_table_backend_schema( ?string $requested_schema ): string { + if ( null === $requested_schema ) { + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + return 'information_schema'; + } + + $is_current_main_schema = 0 === strcasecmp( $this->db_name, $this->main_db_name ) || 0 === strcasecmp( $this->db_name, 'public' ); + if ( $is_current_main_schema ) { + return 'public'; + } + return $this->db_name; + } + + $is_main_schema = 0 === strcasecmp( $requested_schema, $this->main_db_name ) || 0 === strcasecmp( $requested_schema, 'public' ); + if ( $is_main_schema ) { + return 'public'; + } + + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + return 'information_schema'; + } + return $requested_schema; + } + private function get_postgresql_schema_identifier( string $schema_name, string $object_name ): string { + return $this->connection->quote_identifier( $schema_name ) . '.' . $this->connection->quote_identifier( $object_name ); + } + private function translate_mysql_dbdelta_alter_table_query( string $query ): ?array { + $query_tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $query_tokens[0], $query_tokens[1] ) + || WP_MySQL_Lexer::ALTER_SYMBOL !== $query_tokens[0]->id + || WP_MySQL_Lexer::TABLE_SYMBOL !== $query_tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $query_tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $position = 2; + $table_reference = $this->get_mysql_dbdelta_alter_table_target_reference( $query_tokens, $position ); + if ( null === $table_reference || $position >= $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $table_name = $table_reference['table']; + $clause = $this->trim_mysql_statement_fragment( + $this->get_mysql_token_range_bytes( $query, $query_tokens, $position, $statement_end ) + ); + + $tokens = $this->get_mysql_tokens( $clause ); + $statement_end = $this->get_mysql_statement_end_position( $tokens, 0 ); + if ( null === $statement_end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, 0, $statement_end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $normalized_ranges = array(); + $range_count = count( $ranges ); + for ( $i = 0; $i < $range_count; ++$i ) { + $range = $ranges[ $i ]; + if ( isset( $tokens[ $range['start'] ] ) && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $range['start'] ]->id ) { + for ( $j = $i + 1; $j < $range_count; ++$j ) { + $range['end'] = $ranges[ $j ]['end']; + } + $normalized_ranges[] = $range; + break; + } + $normalized_ranges[] = $range; + } + $ranges = $normalized_ranges; + + foreach ( $ranges as $range ) { + if ( $this->contains_unsupported_mysql_column_attribute_alter_action( $tokens, $range['start'], $range['end'] ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'ALTER TABLE' ); + + $statements = array(); + $metadata_operations = array(); + $has_online_ddl_options = false; + $check_names = array(); + $foreign_key_names = array(); + foreach ( $ranges as $range ) { + $translation = $this->translate_mysql_dbdelta_alter_table_action( + $table_schema, + $table_name, + $clause, + $tokens, + $range['start'], + $range['end'], + $check_names, + $foreign_key_names + ); + if ( null === $translation ) { + return null; + } + + $statements = array_merge( $statements, $translation['statements'] ); + if ( 'noop' === ( $translation['metadata']['operation'] ?? '' ) && 'online_ddl_option' === ( $translation['metadata']['option'] ?? '' ) ) { + $has_online_ddl_options = true; + } + if ( 'noop' !== ( $translation['metadata']['operation'] ?? '' ) ) { + $metadata_operations[] = $translation['metadata']; + } + } + + if ( $has_online_ddl_options && $this->has_mysql_dbdelta_metadata_only_index_operation( $metadata_operations ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $this->preflight_mysql_dbdelta_alter_table_metadata_operations( $table_schema, $table_name, $metadata_operations ); + $metadata_count = count( $metadata_operations ); + if ( 1 === $metadata_count ) { + $metadata = array_merge( $metadata_operations[0], $this->get_mysql_key_value_array( 'schema', $table_schema, 'table', $table_name ) ); + } elseif ( 0 === $metadata_count ) { + $metadata = $this->get_mysql_metadata( 'noop', 'schema', $table_schema, 'table', $table_name ); + } else { + $metadata = $this->get_mysql_metadata( 'operations', 'schema', $table_schema, 'table', $table_name, 'operations', $metadata_operations ); + } + return $this->get_mysql_ddl_translation( + $statements, + $metadata + ); + } + private function get_mysql_ddl_translation( array $statements = array(), array $metadata = array() ): array { + return array( + 'statements' => $statements, + 'metadata' => $metadata, + ); + } + private function get_mysql_metadata( string $operation, ...$pairs ): array { + $metadata = array( 'operation' => $operation ); + return array_merge( $metadata, $this->get_mysql_key_value_array( ...$pairs ) ); + } + private function get_mysql_key_value_array( ...$pairs ): array { + $array = array(); + for ( $i = 0; $i < count( $pairs ); $i += 2 ) { + $array[ $pairs[ $i ] ] = $pairs[ $i + 1 ]; + } + return $array; + } + private function get_mysql_dbdelta_noop_translation( ?string $option = null ): array { + $metadata = $this->get_mysql_metadata( 'noop' ); + if ( null !== $option ) { + $metadata['option'] = $option; + } + return $this->get_mysql_ddl_translation( array(), $metadata ); + } + private function preflight_mysql_dbdelta_alter_table_metadata_operations( string $table_schema, string $table_name, array $metadata_operations ): void { + $metadata_operations = $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata_operations ); + list( $added_columns, $added_indexes, $dropped_columns, $dropped_indexes ) = array_fill( 0, 4, array() ); + + foreach ( $metadata_operations as $metadata ) { + $operation = $metadata['operation'] ?? ''; + if ( 'drop_column' === $operation ) { + $column_name = (string) ( $metadata['column'] ?? '' ); + if ( $this->mark_mysql_dbdelta_preflight_dropped_name( $column_name, $added_columns, $dropped_columns ) ) { + foreach ( + $this->get_mysql_index_names_removed_by_dropped_columns( + $table_schema, + $table_name, + array_keys( $dropped_columns ) + ) as $index_name + ) { + $dropped_indexes[ strtolower( $index_name ) ] = true; + } + } + continue; + } + + if ( 'drop_index' === $operation ) { + $this->mark_mysql_dbdelta_preflight_dropped_name( (string) ( $metadata['index'] ?? '' ), $added_indexes, $dropped_indexes ); + continue; + } + + if ( 'change_column' === $operation || 'rename_column' === $operation ) { + $old_column_name = (string) ( $metadata['old_column'] ?? '' ); + $column_name = 'rename_column' === $operation ? (string) ( $metadata['new_column'] ?? '' ) : (string) ( $metadata['column']['name'] ?? '' ); + + $old_column_key = strtolower( $old_column_name ); + if ( '' === $old_column_name || '' === $column_name || strtolower( $column_name ) === $old_column_key ) { + continue; + } + + $this->mark_mysql_dbdelta_preflight_dropped_name( $old_column_name, $added_columns, $dropped_columns ); + } elseif ( 'add_column' === $operation ) { + $column_name = (string) ( $metadata['column']['name'] ?? '' ); + } elseif ( 'add_index' === $operation ) { + $this->preflight_mysql_dbdelta_alter_table_add_index_metadata( + $table_schema, + $table_name, + $metadata['index'] ?? array(), + $added_indexes, + $dropped_indexes + ); + continue; + } else { + continue; + } + + if ( '' !== $column_name ) { + $column_key = strtolower( $column_name ); + if ( + isset( $added_columns[ $column_key ] ) + || ( + ! isset( $dropped_columns[ $column_key ] ) + && $this->mysql_table_has_column_for_translation( $table_schema, $table_name, $column_name ) + ) + ) { + throw new InvalidArgumentException( sprintf( "Duplicate column name '%s'.", $column_name ) ); + } + + $added_columns[ $column_key ] = true; + unset( $dropped_columns[ $column_key ] ); + } + + if ( 'add_column' === $operation ) { + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->preflight_mysql_dbdelta_alter_table_add_index_metadata( + $table_schema, + $table_name, + $index, + $added_indexes, + $dropped_indexes + ); + } + } + } + } + private function mark_mysql_dbdelta_preflight_dropped_name( string $name, array &$added_names, array &$dropped_names ): bool { + if ( '' === $name ) { + return false; + } + + $name_key = strtolower( $name ); + unset( $added_names[ $name_key ] ); + $dropped_names[ $name_key ] = true; + return true; + } + private function get_mysql_index_names_removed_by_dropped_columns( string $table_schema, string $table_name, array $dropped_column_keys ): array { + if ( array() === $dropped_column_keys ) { + return array(); + } + + $rows = $this->get_show_create_table_metadata_rows( 'indexes', $table_schema, $table_name ); + + $dropped_column_lookup = array_fill_keys( $dropped_column_keys, true ); + $indexes = array(); + foreach ( $rows as $row ) { + $index_key = strtolower( (string) $row['key_name'] ); + if ( ! isset( $indexes[ $index_key ] ) ) { + $indexes[ $index_key ] = array( + 'name' => (string) $row['key_name'], + 'columns' => array(), + ); + } + + $indexes[ $index_key ]['columns'][] = strtolower( (string) $row['column_name'] ); + } + + $removed_indexes = array(); + foreach ( $indexes as $index ) { + if ( array() === $index['columns'] ) { + continue; + } + + $all_columns_dropped = true; + foreach ( $index['columns'] as $column_key ) { + if ( ! isset( $dropped_column_lookup[ $column_key ] ) ) { + $all_columns_dropped = false; + break; + } + } + + if ( $all_columns_dropped ) { + $removed_indexes[] = $index['name']; + } + } + return $removed_indexes; + } + private function has_mysql_dbdelta_metadata_only_index_operation( array $metadata_operations ): bool { + foreach ( $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata_operations ) as $metadata ) { + if ( + 'add_index' === ( $metadata['operation'] ?? '' ) + && $this->is_mysql_metadata_only_index_type( (string) ( $metadata['index']['index_type'] ?? '' ) ) + ) { + return true; + } + + if ( 'add_column' !== ( $metadata['operation'] ?? '' ) ) { + continue; + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + if ( $this->is_mysql_metadata_only_index_type( (string) ( $index['index_type'] ?? '' ) ) ) { + return true; + } + } + } + return false; + } + private function flatten_mysql_dbdelta_alter_table_metadata_operations( array $metadata_operations ): array { + $flat_operations = array(); + foreach ( $metadata_operations as $metadata ) { + if ( 'operations' === ( $metadata['operation'] ?? '' ) ) { + $flat_operations = array_merge( + $flat_operations, + $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata['operations'] ?? array() ) + ); + continue; + } + + $flat_operations[] = $metadata; + } + return $flat_operations; + } + private function preflight_mysql_dbdelta_alter_table_add_index_metadata( + string $table_schema, + string $table_name, + array $index, + array &$added_indexes, + array $dropped_indexes + ): void { + $index_name = (string) ( $index['name'] ?? '' ); + if ( '' === $index_name ) { + return; + } + + $index_key = strtolower( $index_name ); + if ( + isset( $added_indexes[ $index_key ] ) + || ( + ! isset( $dropped_indexes[ $index_key ] ) + && $this->mysql_index_metadata_exists( $table_schema, $table_name, $index_name ) + ) + ) { + throw new InvalidArgumentException( sprintf( "Duplicate key name '%s'.", $index_name ) ); + } + + $this->assert_postgresql_catalog_recoverable_mysql_index_metadata( $index ); + $added_indexes[ $index_key ] = true; + } + private function get_mysql_dbdelta_alter_table_target_reference( array $tokens, int &$position ): ?array { + $first_identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + ); + } + + $table_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + $position += 2; + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + ); + } + private function contains_unsupported_mysql_column_attribute_alter_action( array $tokens, int $start, int $end ): bool { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + switch ( $tokens[ $start ]->id ) { + case WP_MySQL_Lexer::ADD_SYMBOL: + return $this->contains_unsupported_mysql_add_column_attribute_alter_action( $tokens, $start, $end ); + + case WP_MySQL_Lexer::MODIFY_SYMBOL: + return $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $this->get_mysql_dbdelta_column_action_position( $tokens, $start + 1 ), $end ); + + case WP_MySQL_Lexer::CHANGE_SYMBOL: + $position = $this->get_mysql_dbdelta_column_action_position( $tokens, $start + 1 ); + if ( null === $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + ++$position; + if ( null === $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + return $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, $position + 1, $end ); + } + return false; + } + private function contains_unsupported_mysql_add_column_attribute_alter_action( array $tokens, int $start, int $end ): bool { + if ( null !== $this->get_mysql_dbdelta_add_definition_action_type( $tokens, $start + 1, $end ) ) { + return false; + } + + $position = $this->get_mysql_dbdelta_column_action_position( $tokens, $start + 1 ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( $parenthesized_end !== $end ) { + return false; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $end - 1 ); + foreach ( $ranges ?? array() as $range ) { + if ( + null === $this->get_mysql_dbdelta_add_definition_action_type( $tokens, $range['start'], $range['end'] ) + && $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $range['start'], $range['end'] ) + ) { + return true; + } + } + return false; + } + return $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $position, $end ); + } + private function get_mysql_dbdelta_column_action_position( array $tokens, int $position ): int { + return isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ? $position + 1 : $position; + } + private function get_mysql_dbdelta_add_definition_action_type( array $tokens, int $start, int $end ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + if ( in_array( $tokens[ $start ]->id, self::MYSQL_DBDELTA_ADD_CONSTRAINT_ACTION_TOKENS, true ) ) { + return 'constraint'; + } + return in_array( $tokens[ $start ]->id, self::MYSQL_DBDELTA_ADD_INDEX_ACTION_TOKENS, true ) ? 'index' : null; + } + private function contains_unsupported_mysql_column_attribute_definition_tokens( array $tokens, int $start, int $end ): bool { + return $start < $end && $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, $start + 1, $end ); + } + private function contains_unsupported_mysql_column_attribute_tokens( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( isset( $tokens[ $i ] ) && in_array( $tokens[ $i ]->id, array( WP_MySQL_Lexer::GENERATED_SYMBOL, WP_MySQL_Lexer::COLUMN_FORMAT_SYMBOL, WP_MySQL_Lexer::STORAGE_SYMBOL, WP_MySQL_Lexer::VISIBLE_SYMBOL, WP_MySQL_Lexer::INVISIBLE_SYMBOL ), true ) ) { + return true; + } + } + return false; + } + private function translate_mysql_dbdelta_alter_table_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + switch ( $tokens[ $start ]->id ) { + case WP_MySQL_Lexer::CHANGE_SYMBOL: + case WP_MySQL_Lexer::MODIFY_SYMBOL: + return $this->translate_mysql_dbdelta_column_change_or_modify_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + + case WP_MySQL_Lexer::ADD_SYMBOL: + return $this->translate_mysql_dbdelta_add_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end, $check_names, $foreign_key_names ); + + case WP_MySQL_Lexer::DROP_SYMBOL: + return $this->translate_mysql_dbdelta_drop_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + + case WP_MySQL_Lexer::ALTER_SYMBOL: + return $this->translate_mysql_dbdelta_alter_column_default_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + + case WP_MySQL_Lexer::RENAME_SYMBOL: + return $this->translate_mysql_dbdelta_rename_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + + case WP_MySQL_Lexer::ORDER_SYMBOL: + if ( $this->is_supported_mysql_dbdelta_order_by_alter_action( $table_name, $tokens, $start, $end ) ) { + return $this->get_mysql_dbdelta_noop_translation(); + } + return null; + } + + return $this->translate_mysql_dbdelta_table_option_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + } + private function translate_mysql_dbdelta_column_change_or_modify_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $position = $this->get_mysql_dbdelta_column_action_position( $tokens, $start + 1 ); + $old_column = null; + if ( WP_MySQL_Lexer::CHANGE_SYMBOL === $tokens[ $start ]->id ) { + $old_column = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $old_column ) { + return null; + } + $old_column = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $old_column ); + ++$position; + } + return $this->translate_mysql_dbdelta_column_change_alter_action( $table_schema, $table_name, $clause, $tokens, $position, $end, $old_column ); + } + private function translate_mysql_dbdelta_add_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $definition_type = $this->get_mysql_dbdelta_add_definition_action_type( $tokens, $start + 1, $end ); + if ( null !== $definition_type ) { + return $this->translate_mysql_dbdelta_add_definition_alter_action( $table_schema, $table_name, $clause, $tokens, $start + 1, $end, $definition_type, $check_names, $foreign_key_names ); + } + return $this->translate_mysql_dbdelta_add_column_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end, $check_names, $foreign_key_names ); + } + private function translate_mysql_dbdelta_add_definition_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, string $definition_type, array &$check_names, array &$foreign_key_names ): ?array { + if ( 'constraint' === $definition_type ) { + return $this->translate_mysql_dbdelta_add_constraint_definition_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end, $check_names, $foreign_key_names ); + } + return $this->translate_mysql_dbdelta_add_index_definition_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + } + private function translate_mysql_dbdelta_drop_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + if ( + $this->is_mysql_dbdelta_token_id_sequence( $tokens, $start + 1, $end, array( WP_MySQL_Lexer::PRIMARY_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ) ) + || ( $start + 3 === $end && isset( $tokens[ $start + 1 ] ) && in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) && 'PRIMARY' === strtoupper( (string) $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ) ) ) + ) { + return $this->translate_mysql_dbdelta_drop_primary_key_alter_action( $table_schema, $table_name ); + } + + $action_id = $tokens[ $start + 1 ]->id ?? null; + $method = WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $action_id ? 'translate_mysql_dbdelta_drop_constraint_alter_action' : ( WP_MySQL_Lexer::CHECK_SYMBOL === $action_id ? 'translate_mysql_dbdelta_drop_check_alter_action' : null ); + if ( $start + 3 === $end && null !== $method ) { + return $this->{$method}( $table_schema, $table_name, $tokens, $start, $end ); + } + + if ( $start + 4 === $end && $this->is_mysql_dbdelta_token_id_sequence( $tokens, $start + 1, $start + 3, array( WP_MySQL_Lexer::FOREIGN_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ) ) ) { + return $this->translate_mysql_dbdelta_drop_foreign_key_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + + if ( isset( $tokens[ $start + 1 ] ) && in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) ) { + return $this->translate_mysql_dbdelta_drop_index_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + return $this->translate_mysql_dbdelta_drop_column_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + private function is_mysql_dbdelta_token_id_sequence( array $tokens, int $start, int $end, array $token_ids ): bool { + if ( count( $token_ids ) !== $end - $start ) { + return false; + } + + foreach ( $token_ids as $offset => $token_id ) { + if ( ! isset( $tokens[ $start + $offset ] ) || $token_id !== $tokens[ $start + $offset ]->id ) { + return false; + } + } + return true; + } + private function translate_mysql_dbdelta_rename_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $table_rename = $this->translate_mysql_dbdelta_rename_table_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + if ( null !== $table_rename ) { + return $table_rename; + } + + $rename_type = $tokens[ $start + 1 ]->id ?? null; + if ( WP_MySQL_Lexer::COLUMN_SYMBOL === $rename_type ) { + return $this->translate_mysql_dbdelta_rename_column_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + if ( in_array( $rename_type, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) ) { + return $this->translate_mysql_dbdelta_rename_index_alter_action( $table_name, $tokens, $start, $end ); + } + return null; + } + private function translate_mysql_dbdelta_table_option_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + if ( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $tokens[ $start ]->id ) { + return $this->translate_mysql_dbdelta_auto_increment_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + + $toggles_mysql_keys = $this->is_mysql_dbdelta_token_id_sequence( $tokens, $start, $end, array( WP_MySQL_Lexer::DISABLE_SYMBOL, WP_MySQL_Lexer::KEYS_SYMBOL ) ) + || $this->is_mysql_dbdelta_token_id_sequence( $tokens, $start, $end, array( WP_MySQL_Lexer::ENABLE_SYMBOL, WP_MySQL_Lexer::KEYS_SYMBOL ) ); + if ( $toggles_mysql_keys ) { + return $this->get_mysql_dbdelta_noop_translation(); + } + + $online_ddl_option_end = $start; + if ( $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $online_ddl_option_end, $end ) && $online_ddl_option_end === $end ) { + return $this->get_mysql_dbdelta_noop_translation( 'online_ddl_option' ); + } + + if ( WP_MySQL_Lexer::COMMENT_SYMBOL === $tokens[ $start ]->id ) { + return $this->translate_mysql_dbdelta_table_comment_alter_action( $tokens, $start, $end ); + } + + $table_option = strtoupper( preg_replace( '/\s+/', ' ', trim( $this->get_mysql_token_range_bytes( $clause, $tokens, $start, $end ) ) ) ); + $optional_assignment_option = '(?:ENGINE|ROW_FORMAT|KEY_BLOCK_SIZE|MAX_ROWS|MIN_ROWS|AVG_ROW_LENGTH|CHECKSUM|DELAY_KEY_WRITE|PACK_KEYS|STATS_PERSISTENT|STATS_AUTO_RECALC|STATS_SAMPLE_PAGES|COMPRESSION|ENCRYPTION|CONNECTION|PASSWORD|INSERT_METHOD|SECONDARY_ENGINE|AUTOEXTEND_SIZE|ENGINE_ATTRIBUTE|SECONDARY_ENGINE_ATTRIBUTE)(?:\s*=\s*|\s+)\S'; + $character_set_option = '(?:DEFAULT\s+)?(?:CHARACTER\s+SET|CHAR\s+SET|CHARSET|COLLATE)(?:\s*=\s*|\s+)\S'; + if ( 1 === preg_match( '/^(?:' . $optional_assignment_option . '|' . $character_set_option . '|CONVERT\s+TO\s+(?:CHARACTER\s+SET|CHAR\s+SET|CHARSET)\b|(?:DATA|INDEX)\s+DIRECTORY\b|TABLESPACE\b|UNION\s*=)/', $table_option ) ) { + return $this->get_mysql_dbdelta_noop_translation(); + } + return null; + } + private function translate_mysql_dbdelta_auto_increment_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + if ( $position + 1 !== $end || ! isset( $tokens[ $position ] ) || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position ] ) || ! preg_match( '/^[0-9]+$/', $tokens[ $position ]->get_value() ) ) { + return null; + } + + $auto_increment_value = (int) $tokens[ $position ]->get_value(); + if ( $auto_increment_value <= 0 ) { + return null; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + return $this->get_mysql_dbdelta_noop_translation(); + } + + $minimum_sequence_value = max( 0, $auto_increment_value - 1 ); + $statements = $this->get_postgresql_auto_increment_alter_statements( $table_schema, $table_name, $auto_increment_column, $minimum_sequence_value ); + return $this->get_mysql_ddl_translation( $statements, $this->get_mysql_metadata( 'set_auto_increment' ) ); + } + private function translate_mysql_dbdelta_table_comment_alter_action( array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + if ( $position + 1 !== $end || ! isset( $tokens[ $position ] ) || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, WP_MySQL_Lexer::NCHAR_TEXT ), true ) ) { + return null; + } + return $this->get_mysql_ddl_translation( array(), $this->get_mysql_metadata( 'set_table_comment', 'comment', $tokens[ $position ]->get_value() ) ); + } + private function translate_mysql_dbdelta_rename_table_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $new_table_position = $start + 1; + if ( + isset( $tokens[ $new_table_position ] ) + && in_array( $tokens[ $new_table_position ]->id, array( WP_MySQL_Lexer::TO_SYMBOL, WP_MySQL_Lexer::AS_SYMBOL ), true ) + ) { + ++$new_table_position; + } + + $position = $new_table_position; + $new_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $new_table_reference || $position !== $end ) { + return null; + } + + $new_table_schema = $this->get_mysql_rename_table_target_backend_schema( $new_table_reference, $table_schema, 'ALTER TABLE' ); + if ( $new_table_schema !== $table_schema ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $new_table_name = $new_table_reference['table']; + return $this->get_mysql_ddl_translation( + $this->get_mysql_rename_table_statements( $table_schema, $table_name, $new_table_name ), + $this->get_mysql_metadata( 'rename_table', 'new_table', $new_table_name ) + ); + } + private function translate_mysql_dbdelta_rename_column_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $rename = $this->get_mysql_dbdelta_rename_identifier_pair( $tokens, $start, $end, array( WP_MySQL_Lexer::COLUMN_SYMBOL ) ); + if ( null === $rename ) { + return null; + } + $old_column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $rename['old'] ); + return $this->get_mysql_ddl_translation( + array( + sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $old_column_name ), + $this->connection->quote_identifier( $rename['new'] ) + ), + ), + $this->get_mysql_metadata( 'rename_column', 'old_column', $old_column_name, 'new_column', $rename['new'] ) + ); + } + private function translate_mysql_dbdelta_rename_index_alter_action( string $table_name, array $tokens, int $start, int $end ): ?array { + $rename = $this->get_mysql_dbdelta_rename_identifier_pair( $tokens, $start, $end, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ) ); + if ( null === $rename || 'PRIMARY' === strtoupper( $rename['old'] ) || 'PRIMARY' === strtoupper( $rename['new'] ) ) { + return null; + } + $old_index_name = $rename['old']; + $new_index_name = $rename['new']; + + $table_schema = $this->get_mysql_writable_table_backend_schema( $this->get_mysql_key_value_array( 'schema', null, 'table', $table_name ), 'ALTER TABLE' ); + + if ( + ! $this->mysql_index_metadata_exists( $table_schema, $table_name, $old_index_name ) + || $this->mysql_index_metadata_exists( $table_schema, $table_name, $new_index_name ) + ) { + return null; + } + + $statements = array( + sprintf( + 'ALTER INDEX %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $old_index_name ), + $this->connection->quote_identifier( $table_name . '__' . $new_index_name ) + ), + ); + return $this->get_mysql_ddl_translation( + $statements, + $this->get_mysql_metadata( 'rename_index', 'old_index', $old_index_name, 'new_index', $new_index_name ) + ); + } + private function get_mysql_dbdelta_rename_identifier_pair( array $tokens, int $start, int $end, array $rename_type_ids ): ?array { + if ( ! isset( $tokens[ $start + 4 ] ) || $start + 5 !== $end || ! in_array( $tokens[ $start + 1 ]->id, $rename_type_ids, true ) || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $start + 3 ]->id ) { + return null; + } + + $old_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + $new_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 4 ] ?? null ); + return null === $old_name || null === $new_name ? null : $this->get_mysql_key_value_array( 'old', $old_name, 'new', $new_name ); + } + private function translate_mysql_dbdelta_column_change_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $position, int $end, ?string $old_column ): ?array { + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $position, $end ); + if ( null === $definition_end || $position >= $definition_end ) { + return null; + } + + $column = $this->parse_mysql_dbdelta_column_definition_fragment( + $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $definition_end ), + $table_name + ); + if ( null === $column ) { + return null; + } + + $old_column = $old_column ?? $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column['metadata']['name'] ); + return $this->get_mysql_ddl_translation( + $this->prepend_mysql_column_helper_type_statements( + $this->get_mysql_dbdelta_change_column_statements( $table_schema, $table_name, $old_column, $column ), + $column + ), + $this->get_mysql_metadata( 'change_column', 'old_column', $old_column, 'column', $column['metadata'], 'indexes', $column['indexes'], 'checks', $column['checks'] ) + ); + } + private function translate_mysql_dbdelta_add_column_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $position = $this->get_mysql_dbdelta_column_action_position( $tokens, $start + 1 ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_parenthesized_columns_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $position, + $end, + $check_names, + $foreign_key_names + ); + } + + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $position, $end ); + if ( null === $definition_end || $position >= $definition_end ) { + return null; + } + return $this->translate_mysql_dbdelta_add_column_definition_alter_action( + $table_schema, + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $definition_end ), + $foreign_key_names + ); + } + private function translate_mysql_dbdelta_add_parenthesized_columns_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $position, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( $parenthesized_end !== $end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $end - 1 ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $statements = array(); + $metadata_operations = array(); + foreach ( $ranges as $range ) { + $definition_type = $this->get_mysql_dbdelta_add_definition_action_type( $tokens, $range['start'], $range['end'] ); + if ( null !== $definition_type ) { + $translation = $this->translate_mysql_dbdelta_add_definition_alter_action( $table_schema, $table_name, $clause, $tokens, $range['start'], $range['end'], $definition_type, $check_names, $foreign_key_names ); + } else { + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $range['start'], $range['end'] ); + if ( null === $definition_end || $range['start'] >= $definition_end ) { + return null; + } + + $translation = $this->translate_mysql_dbdelta_add_column_definition_alter_action( + $table_schema, + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $range['start'], $definition_end ), + $foreign_key_names + ); + } + + if ( null === $translation ) { + return null; + } + + $statements = array_merge( $statements, $translation['statements'] ); + $metadata_operations[] = $translation['metadata']; + } + return $this->get_mysql_ddl_translation( + $statements, + $this->get_mysql_metadata( 'operations', 'operations', $metadata_operations ) + ); + } + private function translate_mysql_dbdelta_add_column_definition_alter_action( string $table_schema, string $table_name, string $definition, array &$foreign_key_names ): ?array { + $column = $this->parse_mysql_dbdelta_column_definition_fragment( $definition, $table_name ); + if ( null === $column ) { + return null; + } + + foreach ( $column['foreign_keys'] as &$foreign_key ) { + $constraint_name = $this->get_next_mysql_foreign_key_constraint_name( $table_schema, $table_name, $foreign_key_names ); + $column['sql'] = $this->replace_mysql_column_fragment_constraint_name( + $column['sql'], + $foreign_key['name'], + $constraint_name + ); + + $foreign_key['name'] = $constraint_name; + $foreign_key['referenced_schema'] = $foreign_key['referenced_schema'] ?? $table_schema; + $foreign_key_names[] = $constraint_name; + } + unset( $foreign_key ); + return $this->get_mysql_ddl_translation( + $this->prepend_mysql_column_helper_type_statements( + array( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $column['sql'] + ), + ), + $column + ), + array( + 'operation' => 'add_column', + 'column' => $column['metadata'], + 'indexes' => $column['indexes'], + 'foreign_keys' => $column['foreign_keys'], + 'checks' => $column['checks'], + ) + ); + } + private function parse_mysql_dbdelta_column_definition_fragment( string $definition, string $table_name ): ?array { + try { + return $this->translate_mysql_column_definition_fragment( $definition, $table_name ); + } catch ( InvalidArgumentException $e ) { + return null; + } + } + private function translate_mysql_dbdelta_add_index_definition_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $definition_start, int $end ): ?array { + $index = $this->translate_mysql_index_definition_fragment( + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $definition_start, $end ), + $table_schema + ); + if ( null === $index ) { + return null; + } + return $this->get_mysql_ddl_translation( + $index['statements'], + $this->get_mysql_metadata( 'add_index', 'index', $index['metadata'] ) + ); + } + private function translate_mysql_dbdelta_add_constraint_definition_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $position, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $constraint_name = null; + $definition_start = $position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[ $position ]->id ) { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id + ) { + $is_unique_constraint = WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id; + $index = $this->translate_mysql_index_definition_fragment( + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $definition_start, $end ), + $table_schema + ); + if ( null === $index ) { + return null; + } + + if ( + $is_unique_constraint + && null !== $constraint_name + && isset( $index['metadata']['columns'][0]['column_name'] ) + && $index['metadata']['name'] === $index['metadata']['columns'][0]['column_name'] + ) { + $index['metadata']['name'] = $constraint_name; + $statement = $this->get_postgresql_mysql_index_create_statement( + $table_name, + $index['metadata'], + $this->get_postgresql_table_identifier_sql( $table_schema, $table_name ), + false + ); + if ( null === $statement ) { + return null; + } + + $index['statements'] = array( $statement ); + } + return $this->get_mysql_ddl_translation( + $index['statements'], + $this->get_mysql_metadata( 'add_index', 'index', $index['metadata'] ) + ); + } + + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_check_alter_action( + $table_name, + $tokens, + $position, + $end, + $constraint_name, + $check_names + ); + } + + if ( WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_foreign_key_alter_action( + $table_schema, + $table_name, + $tokens, + $position, + $end, + $constraint_name, + $foreign_key_names + ); + } + return null; + } + private function translate_mysql_dbdelta_add_check_alter_action( string $table_name, array $tokens, int $check_position, int $end, ?string $constraint_name, array &$check_names ): ?array { + if ( ! isset( $tokens[ $check_position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $check_position + 1 ]->id ) { + return null; + } + + $check_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $check_position + 1, $end ); + if ( null === $check_end || $check_position + 2 >= $check_end - 1 ) { + return null; + } + + $enforced = 'YES'; + if ( $check_end < $end ) { + if ( $check_end + 1 === $end && WP_MySQL_Lexer::ENFORCED_SYMBOL === ( $tokens[ $check_end ]->id ?? null ) ) { + $enforced = 'YES'; + } elseif ( + $check_end + 2 === $end + && WP_MySQL_Lexer::NOT_SYMBOL === ( $tokens[ $check_end ]->id ?? null ) + && WP_MySQL_Lexer::ENFORCED_SYMBOL === ( $tokens[ $check_end + 1 ]->id ?? null ) + ) { + $enforced = 'NO'; + } else { + return null; + } + } + + if ( null === $constraint_name ) { + $constraint_name = $this->get_next_mysql_check_constraint_name( 'public', $table_name, $check_names ); + $check_names[] = $constraint_name; + } + + $postgresql_expression = $this->translate_mysql_check_constraint_expression_to_postgresql( + $tokens, + $check_position + 2, + $check_end - 1 + ); + $mysql_expression = $this->render_mysql_check_constraint_metadata_expression( + $tokens, + $check_position + 2, + $check_end - 1 + ); + + if ( 'NO' === $enforced ) { + $postgresql_expression = 'true'; + } + $statements = array( + sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s)', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ), + $postgresql_expression + ), + ); + return $this->get_mysql_ddl_translation( + $statements, + $this->get_mysql_metadata( + 'add_check', + 'check', + $this->get_mysql_key_value_array( 'name', $constraint_name, 'check_clause', $mysql_expression, 'postgresql_check_clause', $postgresql_expression, 'enforced', $enforced ) + ) + ); + } + private function translate_mysql_check_constraint_expression_to_postgresql( array $tokens, int $start, int $end ): string { + $sql = ''; + $segment_start = $start; + + for ( $position = $start; $position < $end; ++$position ) { + $json_valid = $this->translate_mysql_json_valid_check_constraint_function( $tokens, $position, $end ); + if ( null === $json_valid ) { + continue; + } + + $sql = $this->append_mysql_check_constraint_sql_fragment( + $sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ) + ); + $sql = $this->append_mysql_check_constraint_sql_fragment( $sql, $json_valid['sql'] ); + + $position = $json_valid['position']; + $segment_start = $position + 1; + } + + $sql = $this->append_mysql_check_constraint_sql_fragment( + $sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ) + ); + + if ( '' === $sql ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + return $sql; + } + private function render_mysql_check_constraint_metadata_expression( array $tokens, int $start, int $end ): string { + $sql = ''; + $previous_token = null; + + for ( $position = $start; $position < $end; ++$position ) { + $token = $tokens[ $position ]; + $fragment = $this->translate_mysql_token_to_postgresql( $token, $tokens[ $position + 1 ] ?? null ); + if ( '' === $fragment ) { + continue; + } + + if ( '' === $sql ) { + $sql = $fragment; + } elseif ( + null !== $previous_token + && ( + $this->should_join_mysql_tokens_without_space( $previous_token->id, $token->id ) + || ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id + && null !== $this->get_mysql_identifier_token_value( $previous_token ) + ) + ) + ) { + $sql .= $fragment; + } else { + $sql .= ' ' . $fragment; + } + + $previous_token = $token; + } + + if ( '' === $sql ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + return $sql; + } + private function translate_mysql_json_valid_check_constraint_function( array $tokens, int $position, int $end ): ?array { + $function_name = isset( $tokens[ $position ] ) + ? $this->get_mysql_identifier_token_value( $tokens[ $position ] ) + : null; + if ( + ! isset( $tokens[ $position + 1 ] ) + || null === $function_name + || 'json_valid' !== strtolower( $function_name ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $arguments || 1 !== count( $arguments ) || $arguments[0]['start'] >= $arguments[0]['end'] ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $argument_sql = $this->translate_mysql_check_constraint_expression_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + return array( + 'sql' => sprintf( '(CASE WHEN %1$s IS NULL THEN NULL ELSE (CAST(%1$s AS jsonb) IS NOT NULL) END)', $argument_sql ), + 'position' => $after_close - 1, + ); + } + private function append_mysql_check_constraint_sql_fragment( string $sql, string $fragment ): string { + $fragment = trim( $fragment ); + if ( '' === $fragment ) { + return $sql; + } + + if ( '' === $sql ) { + return $fragment; + } + + $last_character = substr( $sql, -1 ); + $first_character = $fragment[0]; + if ( '(' === $last_character || ')' === $first_character || ',' === $first_character || '.' === $last_character || '.' === $first_character ) { + return $sql . $fragment; + } + return $sql . ' ' . $fragment; + } + private function translate_mysql_dbdelta_add_foreign_key_alter_action( string $table_schema, string $table_name, array $tokens, int $foreign_position, int $end, ?string $constraint_name, array &$foreign_key_names ): ?array { + if ( ! isset( $tokens[ $foreign_position + 1 ] ) || WP_MySQL_Lexer::KEY_SYMBOL !== $tokens[ $foreign_position + 1 ]->id ) { + return null; + } + + $position = $foreign_position + 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + $index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ); + if ( null === $index_name ) { + return null; + } + ++$position; + } + + $columns = $this->parse_mysql_alter_identifier_list( $tokens, $position ); + if ( null === $columns || empty( $columns ) ) { + return null; + } + $columns = array_map( + function ( string $column_name ) use ( $table_schema, $table_name ): string { + return $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + }, + $columns + ); + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::REFERENCES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $referenced_table = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $referenced_table ) { + return null; + } + + $referenced_schema = $this->get_mysql_writable_table_backend_schema( $referenced_table, 'ALTER TABLE' ); + $referenced_columns = $this->parse_mysql_alter_identifier_list( $tokens, $position ); + if ( null === $referenced_columns || count( $referenced_columns ) !== count( $columns ) ) { + return null; + } + $referenced_columns = array_map( + function ( string $column_name ) use ( $referenced_schema, $referenced_table ): string { + return $this->resolve_mysql_existing_alter_column_name( $referenced_schema, $referenced_table['table'], $column_name ); + }, + $referenced_columns + ); + + $rules = $this->parse_mysql_foreign_key_rules( $tokens, $position, $end ); + if ( null === $rules ) { + return null; + } + + if ( null === $constraint_name ) { + $constraint_name = $this->get_next_mysql_foreign_key_constraint_name( $table_schema, $table_name, $foreign_key_names ); + $foreign_key_names[] = $constraint_name; + } else { + $foreign_key_names[] = $constraint_name; + } + + $foreign_key_sql = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)%s%s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + null === $referenced_table['schema'] + ? $this->connection->quote_identifier( $referenced_table['table'] ) + : $this->get_postgresql_schema_identifier( $referenced_schema, $referenced_table['table'] ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $referenced_columns ) ), + 'NO ACTION' === $rules['delete_rule'] ? '' : ' ON DELETE ' . $rules['delete_rule'], + 'NO ACTION' === $rules['update_rule'] ? '' : ' ON UPDATE ' . $rules['update_rule'] + ); + return $this->get_mysql_ddl_translation( + array( $foreign_key_sql ), + $this->get_mysql_metadata( + 'add_foreign_key', + 'foreign_key', + $this->get_mysql_key_value_array( 'name', $constraint_name, 'columns', $columns, 'referenced_schema', $referenced_schema, 'referenced_table', $referenced_table['table'], 'referenced_columns', $referenced_columns, 'update_rule', $rules['update_rule'], 'delete_rule', $rules['delete_rule'] ) + ) + ); + } + private function parse_mysql_alter_identifier_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $identifiers = array(); + while ( isset( $tokens[ $position ] ) ) { + $identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $identifiers; + } + return null; + } + return null; + } + private function parse_mysql_foreign_key_rules( array $tokens, int &$position, int $end ): ?array { + $rules = array( + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ); + $seen = array(); + + while ( $position < $end ) { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'update_rule'; + } elseif ( WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'delete_rule'; + } else { + return null; + } + + if ( isset( $seen[ $rule_key ] ) ) { + return null; + } + + $position += 2; + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) ) { + $rule = strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + } elseif ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id + && in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::NULL_SYMBOL, WP_MySQL_Lexer::DEFAULT_SYMBOL ), true ) + ) { + $rule = WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 1 ]->id ? 'SET NULL' : 'SET DEFAULT'; + $position += 2; + } elseif ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::ACTION_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $rule = 'NO ACTION'; + $position += 2; + } else { + return null; + } + $rules[ $rule_key ] = $rule; + $seen[ $rule_key ] = true; + } + return $rules; + } + private function translate_mysql_dbdelta_drop_index_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + if ( $start + 3 !== $end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $index_name ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $drop_index_query = $this->get_mysql_drop_index_translation( + array( + 'schema' => null, + 'table' => $table_name, + ), + $index_name, + 'ALTER TABLE', + $table_schema + ); + + $drop_index_query['metadata']['operation'] = 'drop_index'; + return $drop_index_query; + } + private function translate_mysql_dbdelta_drop_primary_key_alter_action( string $table_schema, string $table_name ): array { + $constraint_name = $this->get_postgresql_primary_key_constraint_name( $table_schema, $table_name ); + if ( null === $constraint_name ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + return $this->get_mysql_ddl_translation( + array( + sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ) + ), + ), + $this->get_mysql_metadata( 'drop_index', 'schema', $table_schema, 'table', $table_name, 'index', 'PRIMARY' ) + ); + } + private function translate_mysql_dbdelta_drop_constraint_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + if ( 'PRIMARY' === strtoupper( $constraint_name ) ) { + return $this->translate_mysql_dbdelta_drop_primary_key_alter_action( $table_schema, $table_name ); + } + + $matching_constraint_types = array(); + if ( $this->mysql_index_metadata_exists( $table_schema, $table_name, $constraint_name, true ) ) { + $matching_constraint_types[] = 'unique'; + } + if ( $this->mysql_foreign_key_metadata_exists( $table_schema, $table_name, $constraint_name ) ) { + $matching_constraint_types[] = 'foreign_key'; + } + $check_metadata = $this->get_mysql_check_metadata( $table_schema, $table_name, $constraint_name ); + if ( null !== $check_metadata ) { + $matching_constraint_types[] = 'check'; + } + + if ( 1 !== count( $matching_constraint_types ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + if ( 'unique' === $matching_constraint_types[0] ) { + return $this->get_mysql_ddl_translation( + array( + 'DROP INDEX ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $constraint_name ), + ), + $this->get_mysql_metadata( 'drop_index', 'schema', $table_schema, 'table', $table_name, 'index', $constraint_name ) + ); + } + + if ( 'foreign_key' === $matching_constraint_types[0] ) { + return $this->get_mysql_dbdelta_drop_constraint_translation( $table_name, $constraint_name, 'drop_foreign_key' ); + } + + $drop_backend_constraint = 'catalog' === ( $check_metadata['metadata_source'] ?? '' ) + || 'NO' !== strtoupper( (string) $check_metadata['enforced'] ); + return $this->get_mysql_dbdelta_drop_constraint_translation( + $table_name, + $constraint_name, + 'drop_check', + $drop_backend_constraint + ); + } + private function translate_mysql_dbdelta_drop_foreign_key_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + if ( $start + 4 !== $end ) { + return null; + } + + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 3 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + if ( ! $this->mysql_foreign_key_metadata_exists( $table_schema, $table_name, $constraint_name ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + return $this->get_mysql_dbdelta_drop_constraint_translation( $table_name, $constraint_name, 'drop_foreign_key' ); + } + private function translate_mysql_dbdelta_drop_check_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + $check_metadata = $this->get_mysql_check_metadata( $table_schema, $table_name, $constraint_name ); + if ( null === $check_metadata ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $drop_backend_constraint = 'catalog' === ( $check_metadata['metadata_source'] ?? '' ) + || 'NO' !== strtoupper( (string) $check_metadata['enforced'] ); + return $this->get_mysql_dbdelta_drop_constraint_translation( + $table_name, + $constraint_name, + 'drop_check', + $drop_backend_constraint + ); + } + private function get_mysql_dbdelta_drop_constraint_translation( string $table_name, string $constraint_name, string $operation, bool $drop_backend_constraint = true ): array { + return $this->get_mysql_ddl_translation( + $drop_backend_constraint ? array( + sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ) + ), + ) : array(), + $this->get_mysql_metadata( $operation, 'constraint', $constraint_name ) + ); + } + private function translate_mysql_dbdelta_drop_column_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + $position + 2 === $end + && isset( $tokens[ $position + 1 ] ) + && in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) + ) { + --$end; + } + + if ( $position + 1 !== $end ) { + return null; + } + + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name ) { + return null; + } + $column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + return $this->get_mysql_ddl_translation( + array( + sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ), + ), + $this->get_mysql_metadata( 'drop_column', 'column', $column_name ) + ); + } + private function translate_mysql_dbdelta_alter_column_default_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name || ! isset( $tokens[ $position + 1 ] ) ) { + return null; + } + $column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + + $position += 1; + if ( WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::DEFAULT_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $default_start = $position + 2; + if ( $default_start >= $end ) { + return null; + } + + $default = $this->translate_mysql_default_fragment( + $this->get_mysql_token_range_bytes( $clause, $tokens, $default_start, $end ) + ); + if ( null === $default ) { + return null; + } + return $this->get_mysql_dbdelta_alter_column_default_translation( $table_name, $column_name, $default ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DROP_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $position + 1 ]->id + && $position + 2 === $end + ) { + return $this->get_mysql_dbdelta_alter_column_default_translation( $table_name, $column_name, null ); + } + return null; + } + private function get_mysql_dbdelta_alter_column_default_translation( string $table_name, string $column_name, ?array $default_fragment ): array { + $metadata = null === $default_fragment + ? $this->get_mysql_metadata( 'drop_default', 'column', $column_name ) + : $this->get_mysql_metadata( 'set_default', 'column', $column_name, 'default', $default_fragment['metadata'] ); + return $this->get_mysql_ddl_translation( + array( + 'ALTER TABLE ' . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' . $this->connection->quote_identifier( $column_name ) + . ( null === $default_fragment ? ' DROP DEFAULT' : ' SET DEFAULT ' . $default_fragment['sql'] ), + ), + $metadata + ); + } + private function get_mysql_dbdelta_change_column_statements( string $table_schema, string $table_name, string $old_column, array $column ): array { + $new_column = $column['metadata']['name']; + $quoted_table = $this->connection->quote_identifier( $table_name ); + $quoted_column = $this->connection->quote_identifier( $new_column ); + $alter_column_sql = 'ALTER TABLE ' . $quoted_table . ' ALTER COLUMN ' . $quoted_column . ' '; + $statements = array(); + if ( $old_column !== $new_column ) { + $statements[] = 'ALTER TABLE ' . $quoted_table . ' RENAME COLUMN ' . $this->connection->quote_identifier( $old_column ) . ' TO ' . $quoted_column; + } + + $column_metadata = $column['metadata']; + $column_type = $this->get_translated_column_type_from_definition_line( $column['sql'] ); + $is_auto_increment = 'auto_increment' === strtolower( (string) ( $column_metadata['extra'] ?? '' ) ); + $is_auto_increment_integer = $is_auto_increment && $this->is_mysql_integer_family_column_type( (string) ( $column_metadata['type'] ?? '' ) ); + $existing_identity = $this->get_existing_dbdelta_column_identity_metadata( $table_schema, $table_name, $old_column ); + $has_backend_identity = null !== $existing_identity && $this->is_existing_dbdelta_column_backend_identity( $existing_identity ); + $preserve_existing_identity = false; + if ( $is_auto_increment_integer && $has_backend_identity ) { + $existing_mysql_type = (string) ( $existing_identity['mysql_column_type'] ?? '' ); + $preserve_existing_identity = '' !== $existing_mysql_type + ? $this->is_mysql_integer_family_column_type( $existing_mysql_type ) + : in_array( strtolower( trim( (string) ( $existing_identity['data_type'] ?? '' ) ) ), array( 'bigint', 'integer', 'smallint' ), true ); + } + if ( ! $is_auto_increment && $has_backend_identity ) { + $statements[] = $alter_column_sql . 'DROP IDENTITY IF EXISTS'; + } + if ( '' !== $column_type && ! $preserve_existing_identity ) { + $statements[] = $alter_column_sql . 'TYPE ' . $column_type; + } + + $statements[] = $alter_column_sql . ( 'NO' === ( $column_metadata['nullable'] ?? 'YES' ) ? 'SET' : 'DROP' ) . ' NOT NULL'; + + $default_sql = $this->get_translated_column_default_from_definition_line( $column['sql'] ); + if ( ! $preserve_existing_identity ) { + $statements[] = $alter_column_sql . ( null !== $default_sql ? 'SET DEFAULT ' . $default_sql : 'DROP DEFAULT' ); + } + + if ( $is_auto_increment_integer && null !== $existing_identity && ! $has_backend_identity ) { + $statements[] = $alter_column_sql . 'ADD GENERATED BY DEFAULT AS IDENTITY'; + } + + foreach ( $column['indexes'] ?? array() as $index ) { + $statement = $this->get_postgresql_mysql_index_create_statement( $table_name, $index, $quoted_table, '1' === (string) $index['non_unique'] ); + if ( null !== $statement ) { + $statements[] = $statement; + } + } + + foreach ( $column['checks'] ?? array() as $check ) { + $check_clause = 'NO' === strtoupper( (string) ( $check['enforced'] ?? 'YES' ) ) + ? 'true' + : (string) ( $check['postgresql_check_clause'] ?? $check['check_clause'] ); + $statements[] = 'ALTER TABLE ' . $quoted_table . ' ADD CONSTRAINT ' . $this->connection->quote_identifier( (string) $check['name'] ) . ' CHECK (' . $check_clause . ')'; + } + return $statements; + } + private function prepend_mysql_column_helper_type_statements( array $statements, array $column ): array { + return array_merge( $column['helper_type_statements'] ?? array(), $statements ); + } + private function get_mysql_alter_column_definition_end_without_placement( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end ) { + return null; + } + + if ( isset( $tokens[ $end - 1 ] ) && WP_MySQL_Lexer::FIRST_SYMBOL === $tokens[ $end - 1 ]->id ) { + return $end - 1; + } + + if ( + $end - 2 >= $start + && isset( $tokens[ $end - 2 ], $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::AFTER_SYMBOL === $tokens[ $end - 2 ]->id + && null !== $this->get_mysql_alter_identifier_token_value( $tokens[ $end - 1 ] ) + ) { + return $end - 2; + } + + if ( + $end - 1 >= $start + && isset( $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::AFTER_SYMBOL === $tokens[ $end - 1 ]->id + ) { + return null; + } + return $end; + } + private function get_mysql_alter_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + return $this->get_mysql_index_identifier_token_value( $token ); + } + private function resolve_mysql_existing_alter_column_name( string $table_schema, string $table_name, string $column_name ): string { + return $this->get_mysql_table_column_name( $table_schema, $table_name, $column_name ) ?? $column_name; + } + private function is_supported_mysql_dbdelta_order_by_alter_action( string $table_name, array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $position = $start + 2; + if ( $position >= $end ) { + return false; + } + + while ( $position < $end ) { + $identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $identifier ) { + return false; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) { + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $column_name || 0 !== strcasecmp( $identifier, $table_name ) ) { + return false; + } + $position += 2; + } + + if ( + $position < $end + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::ASC_SYMBOL, WP_MySQL_Lexer::DESC_SYMBOL ), true ) + ) { + ++$position; + } + + if ( $position === $end ) { + return true; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + if ( $position >= $end ) { + return false; + } + } + return false; + } + private function get_existing_dbdelta_column_identity_metadata( string $table_schema, string $table_name, string $column_name ): ?array { + $column_comment_sql = 'c.column_comment'; + $column_type = $this->get_direct_information_schema_catalog_column_type_expression( + 'c', + 'c.identity_sequence_comment', + $column_comment_sql + ); + $extra = $this->get_direct_information_schema_column_extra_expression( 'c', true, $column_comment_sql ); + + $stmt = $this->connection->query( + $this->get_postgresql_catalog_column_metadata_sql( + sprintf( + 'c.data_type, + c.is_identity, + c.column_default, + %1$s AS mysql_column_type, + %2$s AS mysql_extra', + $column_type, + $extra + ), + true + ), + array( $table_schema, $table_name, $column_name ) + ); + + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return 1 === count( $rows ) ? $rows[0] : null; + } + private function is_existing_dbdelta_column_identity( array $metadata ): bool { + if ( 'auto_increment' === strtolower( (string) ( $metadata['mysql_extra'] ?? '' ) ) ) { + return true; + } + return $this->is_existing_dbdelta_column_backend_identity( $metadata ); + } + private function is_existing_dbdelta_column_backend_identity( array $metadata ): bool { + if ( 'YES' === strtoupper( (string) ( $metadata['is_identity'] ?? '' ) ) ) { + return true; + } + + $column_default = ltrim( (string) ( $metadata['column_default'] ?? '' ) ); + return 0 === stripos( $column_default, 'nextval(' ); + } + 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|int1|int2|int3|int4|int8|integer|mediumint|smallint|tinyint)(?:\(\d+\))?$/', $column_type ); + } + private function translate_mysql_drop_table_query( string $query ): ?array { + $drop_query = $this->get_mysql_drop_table_or_view_query( $query, WP_MySQL_Lexer::TABLE_SYMBOL, 'DROP TABLE', true ); + return null === $drop_query + ? null + : array( + 'statements' => $this->get_postgresql_drop_table_or_view_statements( 'DROP TABLE', $drop_query ), + 'targets' => $drop_query['targets'], + ); + } + private function translate_mysql_drop_view_query( string $query ): ?array { + $drop_query = $this->get_mysql_drop_table_or_view_query( $query, WP_MySQL_Lexer::VIEW_SYMBOL, 'DROP VIEW', false ); + return null === $drop_query + ? null + : array( 'statements' => $this->get_postgresql_drop_table_or_view_statements( 'DROP VIEW', $drop_query ) ); + } + private function get_mysql_drop_table_or_view_query( string $query, int $object_symbol, string $statement_type, bool $allow_temporary ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( WP_MySQL_Lexer::DROP_SYMBOL !== ( $tokens[0]->id ?? null ) ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + $temporary = $allow_temporary && WP_MySQL_Lexer::TEMPORARY_SYMBOL === ( $tokens[1]->id ?? null ); + $position = $temporary ? 2 : 1; + + if ( ( $tokens[ $position ]->id ?? null ) !== $object_symbol ) { + return null; + } + + ++$position; + $if_exists = $this->consume_mysql_if_exists_sequence( $tokens, $position ); + + $targets = array(); + while ( $position < $statement_end ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + if ( $temporary && null !== $table_reference['schema'] ) { + $this->get_mysql_writable_table_backend_schema( $table_reference, $statement_type ); + } + $table_schema = $temporary + ? null + : $this->get_mysql_schema_aware_table_backend_schema( $table_reference, $statement_type ); + $targets[] = array( + 'identifier' => $temporary + ? $this->get_temporary_drop_table_schema_name() . '.' . $this->connection->quote_identifier( $table_reference['table'] ) + : $this->get_mysql_schema_aware_table_identifier( $table_reference, $table_schema ), + 'schema' => $table_schema, + 'table' => $table_reference['table'], + ); + + $token_id = $tokens[ $position ]->id ?? null; + if ( + $position === $statement_end + || ( + $position + 1 === $statement_end + && in_array( $token_id, array( WP_MySQL_Lexer::RESTRICT_SYMBOL, WP_MySQL_Lexer::CASCADE_SYMBOL ), true ) + ) + ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $token_id ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + ++$position; + if ( $position === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + } + + if ( array() === $targets ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + return compact( 'if_exists', 'targets' ); + } + private function get_mysql_schema_aware_table_identifier( array $table_reference, string $table_schema ): string { + if ( 0 === strcasecmp( $table_schema, 'public' ) ) { + return $this->connection->quote_identifier( $table_reference['table'] ); + } + return $this->get_postgresql_schema_identifier( $table_schema, $table_reference['table'] ); + } + private function get_postgresql_drop_table_or_view_statements( string $statement_type, array $drop_query ): array { + $statements = array(); + foreach ( $drop_query['targets'] as $drop_target ) { + if ( 'DROP TABLE' === $statement_type && null !== $drop_target['schema'] ) { + $on_update_columns = $this->get_postgresql_catalog_on_update_current_timestamp_column_names( $drop_target['schema'], $drop_target['table'] ); + foreach ( $on_update_columns as $column_name ) { + $statements = array_merge( + $statements, + $this->get_postgresql_on_update_current_timestamp_drop_statements( $drop_target['schema'], $drop_target['table'], $column_name ) + ); + } + } + + $statements[] = sprintf( '%s %s%s', $statement_type, $drop_query['if_exists'] ? 'IF EXISTS ' : '', $drop_target['identifier'] ); + } + return $statements; + } + private function translate_mysql_drop_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( WP_MySQL_Lexer::DROP_SYMBOL !== ( $tokens[0]->id ?? null ) || WP_MySQL_Lexer::INDEX_SYMBOL !== ( $tokens[1]->id ?? null ) ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + $index_name = $this->get_mysql_index_identifier_token_value( $tokens[2] ?? null ); + $position = 4; + $table_reference = WP_MySQL_Lexer::ON_SYMBOL === ( $tokens[3]->id ?? null ) ? $this->get_mysql_table_administration_table_reference( $tokens, $position ) : null; + if ( null === $statement_end || null === $index_name || null === $table_reference || ! $this->consume_mysql_supported_drop_index_options( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP INDEX' ); + $table_name = $table_reference['table']; + $table_identifier = null === $table_reference['schema'] ? $this->connection->quote_identifier( $table_name ) : $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $constraint_name = $this->get_postgresql_primary_key_constraint_name( $table_schema, $table_name ); + if ( null === $constraint_name ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + return array( + 'statements' => array( sprintf( 'ALTER TABLE %s DROP CONSTRAINT %s', $table_identifier, $this->connection->quote_identifier( $constraint_name ) ) ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => 'PRIMARY', + ), + ); + } + return $this->get_mysql_drop_index_translation( $table_reference, $index_name, 'DROP INDEX' ); + } + private function consume_mysql_supported_drop_index_options( array $tokens, int &$position, int $statement_end ): bool { + while ( $position < $statement_end ) { + if ( ! $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $position, $statement_end ) ) { + return false; + } + } + return true; + } + private function get_unsupported_mysql_drop_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( null !== $this->consume_mysql_table_administration_token_sequence( $tokens, $position, self::MYSQL_SPATIAL_REFERENCE_SYSTEM_STATEMENT_TOKENS ) ) { + return 'Unsupported DROP SPATIAL REFERENCE SYSTEM statement.'; + } + + return $this->get_mysql_unsupported_statement_message( 'drop', $tokens[ $position ]->id ?? null ); + } + private function get_mysql_unsupported_statement_message( string $statement_family, ?int $token_id, ?string $default_message = null ): ?string { + foreach ( self::MYSQL_UNSUPPORTED_STATEMENT_MESSAGE_DESCRIPTORS as $descriptor ) { + if ( $statement_family === $descriptor[0] && $token_id === $descriptor[1] ) { + return $descriptor[2]; + } + } + return $default_message; + } + private function get_mysql_drop_index_translation( array $table_reference, string $index_name, string $statement_type, ?string $table_schema = null ): array { + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + $table_schema = null === $table_schema + ? $this->get_mysql_schema_aware_table_backend_schema( $table_reference, $statement_type ) + : $table_schema; + $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, + ), + ); + } + private function translate_mysql_rename_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( WP_MySQL_Lexer::RENAME_SYMBOL !== ( $tokens[0]->id ?? null ) || WP_MySQL_Lexer::TABLE_SYMBOL !== ( $tokens[1]->id ?? null ) ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end || 2 === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $statements = array(); + $metadata_source_names = array(); + for ( $position = 2; ; ) { + $old_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $old_table_reference || WP_MySQL_Lexer::TO_SYMBOL !== ( $tokens[ $position++ ]->id ?? null ) ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $new_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $new_table_reference ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $table_schema = $this->get_mysql_schema_aware_table_backend_schema( $old_table_reference, 'RENAME TABLE' ); + if ( $this->get_mysql_rename_table_target_backend_schema( $new_table_reference, $table_schema, 'RENAME TABLE' ) !== $table_schema ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $old_table_name = $old_table_reference['table']; + $new_table_name = $new_table_reference['table']; + $old_metadata_key = strtolower( $table_schema ) . "\0" . strtolower( $old_table_name ); + $metadata_source_name = $metadata_source_names[ $old_metadata_key ] ?? $old_table_name; + + $statements = array_merge( + $statements, + $this->get_mysql_rename_table_statements( $table_schema, $old_table_name, $new_table_name, $metadata_source_name ) + ); + + unset( $metadata_source_names[ $old_metadata_key ] ); + $metadata_source_names[ strtolower( $table_schema ) . "\0" . strtolower( $new_table_name ) ] = $metadata_source_name; + + if ( $position === $statement_end ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) || ++$position === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + } + return array( 'statements' => $statements ); + } + private function get_mysql_rename_table_target_backend_schema( array $table_reference, string $default_schema, string $statement_type ): string { + return null === $table_reference['schema'] ? $default_schema : $this->get_mysql_explicit_table_backend_schema( $table_reference['schema'], $statement_type, true ); + } + private function get_mysql_rename_table_statements( string $table_schema, string $old_table_name, string $new_table_name, ?string $metadata_table_name = null ): array { + $statements = array( + sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $old_table_name ), + $this->connection->quote_identifier( $new_table_name ) + ), + ); + return array_merge( + $statements, + $this->get_mysql_rename_table_on_update_current_timestamp_statements( $table_schema, $old_table_name, $new_table_name, $metadata_table_name ?? $old_table_name ), + $this->get_mysql_rename_table_index_statements( $table_schema, $old_table_name, $new_table_name, $metadata_table_name ?? $old_table_name ) + ); + } + private function get_mysql_rename_table_on_update_current_timestamp_statements( string $table_schema, string $old_table_name, string $new_table_name, string $metadata_table_name ): array { + $columns = $this->get_postgresql_catalog_on_update_current_timestamp_column_names( $table_schema, $metadata_table_name ); + + $statements = array(); + foreach ( $columns as $column_name ) { + $trigger_name = $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $old_table_name, $column_name ); + $function_name = $this->get_postgresql_on_update_current_timestamp_function_name( $table_schema, $old_table_name, $column_name ); + $statements = array_merge( + $statements, + array( + sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->connection->quote_identifier( $trigger_name ), + $this->get_postgresql_schema_identifier( $table_schema, $new_table_name ) + ), + sprintf( + 'DROP FUNCTION IF EXISTS %s()', + $this->get_postgresql_schema_identifier( $table_schema, $function_name ) + ), + ), + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $new_table_name, $column_name ) + ); + } + return $statements; + } + private function update_mysql_table_schema_state_after_drop( array $drop_query ): void { + foreach ( $drop_query['targets'] as $drop_target ) { + if ( null === $drop_target['schema'] || $this->is_mysql_temporary_schema_name( (string) $drop_target['schema'] ) ) { + $this->forget_mysql_temporary_table( $drop_target['table'] ); + } + } + } + private function get_postgresql_catalog_on_update_current_timestamp_column_names( string $table_schema, string $table_name ): array { + $stmt = $this->connection->query( + 'SELECT a.attname + FROM pg_catalog.pg_class t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_attribute a + ON a.attrelid = t.oid + INNER JOIN pg_catalog.pg_trigger tr + ON tr.tgrelid = t.oid + AND tr.tgname = \'__wp_pg_on_update_\' || ' . $this->get_postgresql_on_update_current_timestamp_trigger_hash_sql( 'n.nspname', 't.relname', 'a.attname' ) . ' + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND a.attnum > 0 + AND NOT a.attisdropped + AND NOT tr.tgisinternal + ORDER BY a.attnum', + array( $table_schema, $table_name ) + ); + return array_map( 'strval', $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) ); + } + private function get_mysql_rename_table_index_statements( string $table_schema, string $old_table_name, string $new_table_name, string $metadata_table_name ): array { + $index_prefix = $metadata_table_name . '__'; + $stmt = $this->connection->query( + 'SELECT idx.relname + 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 + WHERE n.nspname = ? + AND t.relname = ? + AND t.relkind IN (\'r\', \'p\') + AND i.indisvalid + AND i.indislive + AND NOT i.indisprimary + AND LEFT(idx.relname, CHAR_LENGTH(?)) = ? + ORDER BY idx.relname', + array( $table_schema, $metadata_table_name, $index_prefix, $index_prefix ) + ); + + $key_names = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $index_name ) { + $key_name = substr( (string) $index_name, strlen( $index_prefix ) ); + if ( '' !== $key_name ) { + $key_names[] = $key_name; + } + } + + $statements = array(); + foreach ( $key_names as $key_name ) { + $statements[] = sprintf( + 'ALTER INDEX %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $old_table_name . '__' . $key_name ), + $this->connection->quote_identifier( $new_table_name . '__' . $key_name ) + ); + } + return $statements; + } + private function get_temporary_drop_table_schema_name(): string { + return 'pg_temp'; + } + 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 ); + } + private function translate_mysql_column_definition_fragment( string $definition, ?string $table_name = null ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $wrapper_table = $table_name ?? '__wp_dbdelta_column'; + $wrapper = sprintf( 'CREATE TABLE %s (%s)', $this->quote_mysql_identifier( $wrapper_table ), $definition ); + + $statements = $translator->translate_schema( $wrapper ); + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['columns'][0] ) ) { + return null; + } + $column = array( + 'sql' => $this->get_first_translated_create_table_definition( $statements[0] ), + 'metadata' => $metadata[0]['columns'][0], + 'indexes' => $metadata[0]['indexes'] ?? array(), + 'foreign_keys' => $metadata[0]['foreign_keys'] ?? array(), + 'checks' => $metadata[0]['checks'] ?? array(), + 'helper_type_statements' => $translator->get_postgresql_mysql_helper_type_statements( $wrapper ), + ); + $this->maybe_append_mysql_inline_key_column_metadata( $definition, $column ); + return $column; + } + private function maybe_append_mysql_inline_key_column_metadata( string $definition, array &$column ): void { + if ( ! empty( $column['indexes'] ) || ! $this->column_definition_fragment_has_inline_secondary_key( $definition ) ) { + return; + } + + $column_name = (string) ( $column['metadata']['name'] ?? '' ); + $column_type = (string) ( $column['metadata']['type'] ?? '' ); + $column['indexes'][] = array( + 'name' => $column_name, + 'ordinal' => 1, + 'non_unique' => '1', + 'index_type' => 'BTREE', + 'comment' => '', + 'columns' => array( + array( + 'column_name' => $column_name, + 'seq_in_index' => 1, + 'sub_part' => $this->get_mysql_implicit_index_sub_part( $column_type ), + ), + ), + ); + } + private function column_definition_fragment_has_inline_secondary_key( string $definition ): bool { + $tokens = $this->get_mysql_tokens( $definition ); + $depth = 0; + $previous_token_id = null; + foreach ( $tokens as $position => $token ) { + if ( WP_MySQL_Lexer::EOF === $token->id ) { + break; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + $previous_token_id = $token->id; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + $depth = max( 0, $depth - 1 ); + $previous_token_id = $token->id; + continue; + } + + if ( + 0 < $position + && 0 === $depth + && in_array( $token->id, array( WP_MySQL_Lexer::KEY_SYMBOL, WP_MySQL_Lexer::INDEX_SYMBOL ), true ) + && ! in_array( $previous_token_id, array( WP_MySQL_Lexer::PRIMARY_SYMBOL, WP_MySQL_Lexer::UNIQUE_SYMBOL, WP_MySQL_Lexer::FOREIGN_SYMBOL ), true ) + ) { + return true; + } + + $previous_token_id = $token->id; + } + return false; + } + private function get_mysql_implicit_index_sub_part( string $column_type ): ?int { + if ( ! preg_match( '/^(?:var)?char\((\d+)\)$/i', $column_type, $matches ) ) { + return null; + } + + $length = (int) $matches[1]; + return $length > 191 ? 191 : null; + } + private function replace_mysql_column_fragment_constraint_name( string $sql, string $old_name, string $new_name ): string { + $old_sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $old_name ); + $new_sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $new_name ); + + if ( false === strpos( $sql, $old_sql ) ) { + throw new InvalidArgumentException( 'Translated column definition has an unexpected constraint name.' ); + } + return str_replace( $old_sql, $new_sql, $sql ); + } + private function translate_mysql_index_definition_fragment( string $table_name, string $definition, ?string $table_schema = null ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes, true ); + $wrapper = 'CREATE TABLE __wp_dbdelta_index (__wp_dummy int, ' . $definition . ')'; + + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['indexes'][0] ) ) { + return null; + } + + $index = $metadata[0]['indexes'][0]; + if ( null !== $table_schema ) { + foreach ( $index['columns'] as &$column ) { + $column['column_name'] = $this->resolve_mysql_existing_alter_column_name( + $table_schema, + $table_name, + (string) $column['column_name'] + ); + } + unset( $column ); + + if ( 'BTREE' === strtoupper( (string) ( $index['index_type'] ?? 'BTREE' ) ) && isset( $index['columns'][0]['column_name'] ) ) { + $column_type = $this->get_mysql_table_column_type( $table_schema, $table_name, (string) $index['columns'][0]['column_name'] ); + if ( is_string( $column_type ) && $this->is_mysql_spatial_column_type( $column_type ) ) { + $index['index_type'] = 'SPATIAL'; + } + } + if ( 'SPATIAL' === strtoupper( (string) ( $index['index_type'] ?? 'BTREE' ) ) ) { + foreach ( $index['columns'] as &$column ) { + if ( null === $column['sub_part'] ) { + $column['sub_part'] = $this->get_mysql_metadata_only_index_sql_sub_part( 'SPATIAL', null ); + } + } + unset( $column ); + } + } + + $index_table_schema = null !== $table_schema && 'public' !== $table_schema ? $table_schema : null; + $postgresql_table = null === $index_table_schema + ? $this->connection->quote_identifier( $table_name ) + : $this->get_postgresql_table_identifier_sql( $index_table_schema, $table_name ); + $use_sub_parts = 'PRIMARY' !== strtoupper( (string) $index['name'] ); + $statement = $this->get_postgresql_mysql_index_create_statement( + $table_name, + $index, + $postgresql_table, + $use_sub_parts, + $index_table_schema + ); + $statements = null === $statement ? array() : array( $statement ); + return array( + 'statements' => $statements, + 'metadata' => $index, + ); + } + 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]; + } + private function get_translated_column_type_from_definition_line( string $definition_line ): string { + if ( ! preg_match( '/^"(?:""|[^"])+"\s+(?P.+)$/s', $definition_line, $matches ) ) { + return ''; + } + + $definition = $this->remove_translated_inline_key_constraints_from_column_definition( $matches['definition'] ); + $stop_at = strlen( $definition ); + foreach ( array( ' GENERATED ', ' NOT NULL', ' DEFAULT ' ) as $marker ) { + $position = stripos( $definition, $marker ); + if ( false !== $position && $position < $stop_at ) { + $stop_at = $position; + } + } + return trim( substr( $definition, 0, $stop_at ) ); + } + private function get_translated_column_default_from_definition_line( string $definition_line ): ?string { + $definition_line = preg_replace( + '/\s+GENERATED\s+BY\s+DEFAULT\s+AS\s+IDENTITY\b/i', + '', + $definition_line + ); + $definition_line = $this->remove_translated_inline_key_constraints_from_column_definition( $definition_line ); + + if ( ! preg_match( '/\sDEFAULT\s+(?P.+)$/is', $definition_line, $matches ) ) { + return null; + } + return trim( $matches['default'] ); + } + private function remove_translated_inline_key_constraints_from_column_definition( string $definition ): string { + return preg_replace( + array( + '/\s+PRIMARY\s+KEY\b/i', + '/\s+CONSTRAINT\s+"(?:""|[^"])+"\s+UNIQUE\b/i', + '/\s+CONSTRAINT\s+"(?:""|[^"])+"\s+CHECK\s*\(.+\)\s*$/is', + '/\s+CHECK\s*\(.+\)\s*$/is', + '/\s+UNIQUE\b/i', + ), + '', + $definition + ); + } + private function translate_mysql_default_fragment( string $fragment ): ?array { + $fragment = $this->trim_mysql_statement_fragment( $fragment ); + $tokens = $this->get_mysql_tokens( $fragment ); + $end = $this->get_mysql_statement_end_position( $tokens, 0 ); + + $current_timestamp_default = $this->get_mysql_current_timestamp_default_fragment_data( $tokens, $end ); + if ( null !== $current_timestamp_default ) { + return array( + 'sql' => $this->get_postgresql_mysql_current_timestamp_sql( $current_timestamp_default['fsp'] ), + 'metadata' => $current_timestamp_default['metadata'], + ); + } + + if ( 1 !== $end ) { + return null; + } + + $token = $tokens[0]; + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + return array( + 'sql' => 'NULL', + 'metadata' => null, + ); + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return array( + 'sql' => $this->translate_mysql_token_to_postgresql( $token ), + 'metadata' => $token->get_value(), + ); + } + return null; + } + private function get_mysql_current_timestamp_default_fragment_data( array $tokens, ?int $end ): ?array { + if ( null === $end || ! isset( $tokens[0] ) ) { + return null; + } + + $token = $tokens[0]; + if ( WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL === $token->id ) { + $function_name = 'CURRENT_TIMESTAMP'; + } elseif ( WP_MySQL_Lexer::NOW_SYMBOL === $token->id ) { + $function_name = 'CURRENT_TIMESTAMP' === strtoupper( $token->get_value() ) + ? 'CURRENT_TIMESTAMP' + : 'now'; + } else { + return null; + } + + if ( 1 === $end ) { + if ( 'CURRENT_TIMESTAMP' !== $function_name ) { + return null; + } + + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + ( 3 !== $end && 4 !== $end ) + || ! isset( $tokens[1], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $end - 1 ]->id + ) { + return null; + } + + if ( 4 === $end ) { + if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::INT_NUMBER !== $tokens[2]->id ) { + return null; + } + $fsp_value = $tokens[2]->get_value(); + if ( 1 !== preg_match( '/^[0-6]$/', $fsp_value ) ) { + return null; + } + $fsp = (int) $fsp_value; + $metadata = sprintf( '%s(%d)', $function_name, $fsp ); + } else { + $fsp = 0; + $metadata = 'CURRENT_TIMESTAMP'; + if ( 'CURRENT_TIMESTAMP' !== $function_name ) { + $metadata = $function_name . '()'; + } + } + + return array( + 'metadata' => $metadata, + 'fsp' => $fsp, + ); + } + private function trim_mysql_statement_fragment( string $fragment ): string { + return rtrim( trim( $fragment ), "; \t\n\r\0\x0B" ); + } + 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; + } + private function get_describe_table_reference( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || ( WP_MySQL_Lexer::DESCRIBE_SYMBOL !== $tokens[0]->id && WP_MySQL_Lexer::DESC_SYMBOL !== $tokens[0]->id ) ) { + return null; + } + + $position = 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported DESCRIBE statement.' ); + } + + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) || ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) ) { + if ( null === $this->get_direct_information_schema_relation_columns( $table_name ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + return array_combine( array( 'schema', 'table' ), array( 'information_schema', $table_name ) ); + } + return array_combine( array( 'schema', 'table' ), array( $this->get_mysql_writable_table_backend_schema( $table_reference, 'DESCRIBE' ), $table_name ) ); + } + private function get_show_tables_query( string $query ): ?array { + return $this->get_mysql_metadata_table_show_query( $query, 'tables' ); + } + private function get_show_table_status_query( string $query ): ?array { + return $this->get_mysql_metadata_table_show_query( $query, 'table_status' ); + } + private function get_mysql_metadata_table_show_query( string $query, string $result_type ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + $is_tables = 'tables' === $result_type; + $position = 1; + $is_full = false; + if ( $is_tables && isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + foreach ( $is_tables ? array( WP_MySQL_Lexer::TABLES_SYMBOL ) : array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::STATUS_SYMBOL ) as $token_id ) { + if ( ( $tokens[ $position ]->id ?? null ) !== $token_id ) { + return null; + } + ++$position; + } + $statement_type = $is_tables ? 'SHOW TABLES' : 'SHOW TABLE STATUS'; + $unsupported_message = 'Unsupported ' . $statement_type . ' statement.'; + $database_name = $this->get_mysql_show_database_name( $tokens, $position, $unsupported_message ); + $schema_name = $this->get_mysql_show_database_backend_schema( $database_name, $statement_type ); + if ( ! $is_tables ) { + return array_combine( array( 'database', 'schema', 'filter' ), array( $database_name, $schema_name, $this->get_mysql_show_result_filter( $tokens, $position, 'Name', $this->get_mysql_show_output_columns( 'table_status' ), $unsupported_message, self::MYSQL_SHOW_TABLE_STATUS_NUMERIC_COLUMNS ) ) ); + } + $filter = $this->get_mysql_metadata_show_like_where_clause( $tokens, $position, $this->get_mysql_show_column_map( $this->get_show_tables_output_columns( $database_name, $is_full ) ), $unsupported_message, array(), true, true ); + if ( null === $filter || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + return array_combine( array( 'full', 'schema', 'database', 'like', 'where' ), array( $is_full, $schema_name, $database_name, $filter['like'], $filter['where'] ) ); + } + private function get_mysql_metadata_show_like_where_clause( + array $tokens, + int &$position, + array $allowed_columns, + string $unsupported_message, + array $numeric_columns = array(), + bool $supports_like = true, + bool $return_null_on_invalid_like = false + ): ?array { + $like = null; + if ( $supports_like && isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) || ! $this->is_mysql_string_literal_token( $tokens[ $position + 1 ] ) ) { + if ( $return_null_on_invalid_like ) { + return null; + } + throw new InvalidArgumentException( $unsupported_message ); + } + + $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_message ); + } + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + $where = null === $statement_end ? null : $this->get_mysql_show_where_expression_filter_until( $tokens, $position, $statement_end, $allowed_columns, $numeric_columns ); + if ( null === $where ) { + throw new InvalidArgumentException( $unsupported_message ); + } + $position = $statement_end; + } + return array_combine( array( 'like', 'where' ), array( $like, $where ) ); + } + private function get_mysql_show_database_name( array $tokens, int &$position, string $unsupported_message ): string { + $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_message ); + } + $position += 2; + } + return $database_name; + } + private function get_mysql_show_database_backend_schema( string $database_name, string $statement_type ): string { + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + return 'information_schema'; + } + if ( 0 === strcasecmp( $database_name, $this->main_db_name ) || 0 === strcasecmp( $database_name, 'public' ) ) { + return 'public'; + } + if ( ! $this->is_postgresql_internal_schema( $database_name ) ) { + return $database_name; + } + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + private function is_postgresql_internal_schema( string $schema_name ): bool { + return 0 === strcasecmp( $schema_name, 'pg_catalog' ) || 0 === strncasecmp( $schema_name, 'pg_', 3 ); + } + 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; + } + + $position = 3; + $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 SHOW CREATE TABLE statement.' ); + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $table_reference['schema'] ); + if ( $this->is_postgresql_internal_schema( $schema_name ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + + return array( + 'schema' => $schema_name, + 'table' => $table_reference['table'], + ); + } + private function is_mysql_unsigned_integer_token( WP_MySQL_Token $token ): bool { + return in_array( $token->id, self::MYSQL_UNSIGNED_INTEGER_TOKENS, true ); + } + private function get_show_create_database_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[1]->id + || ( + WP_MySQL_Lexer::DATABASE_SYMBOL !== $tokens[2]->id + && WP_MySQL_Lexer::SCHEMA_SYMBOL !== $tokens[2]->id + ) + ) { + return null; + } + + $position = 3; + $if_not_exists = $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + + $database = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, true ); + if ( null === $database || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE DATABASE statement.' ); + } + return array( + 'database' => $database, + 'if_not_exists' => $if_not_exists, + ); + } + private function get_show_grants_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::GRANTS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( $this->is_at_mysql_query_end( $tokens, 2 ) ) { + return array(); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + $position = 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_principal( $tokens, $position + 1, $statement_end ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_role_list( $tokens, $position + 1, $statement_end ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + } + + if ( $position === $statement_end ) { + return array(); + } + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + private function parse_supported_show_grants_principal( array $tokens, int $start, int $end ): ?int { + if ( ! isset( $tokens[ $start ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::CURRENT_USER_SYMBOL === $tokens[ $start ]->id ) { + $position = $start + 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + return $position; + } + return $this->parse_supported_show_grants_account_name( $tokens, $start, $end ); + } + private function parse_supported_show_grants_role_list( array $tokens, int $start, int $end ): ?int { + $position = $this->parse_supported_show_grants_account_name( $tokens, $start, $end ); + if ( null === $position ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_account_name( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + } + return $position; + } + private function parse_supported_show_grants_account_name( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end || ! $this->is_supported_show_grants_name_part( $tokens[ $start ] ?? null ) ) { + return null; + } + + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return $position + 1; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + if ( ! $this->is_supported_show_grants_name_part( $tokens[ $position + 1 ] ?? null ) ) { + return null; + } + return $position + 2; + } + return $position; + } + private function is_supported_show_grants_name_part( ?WP_MySQL_Token $token ): bool { + return null !== $token + && ( + null !== $this->get_mysql_identifier_token_value( $token, true ) + || WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + ); + } + private function get_show_diagnostics_query( string $query, int $diagnostic_token, string $diagnostic_name ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::COUNT_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ], $tokens[ $position + 4 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::MULT_OPERATOR !== $tokens[ $position + 2 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $position += 4; + if ( WP_MySQL_Lexer::WARNINGS_SYMBOL !== $tokens[ $position ]->id && WP_MySQL_Lexer::ERRORS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( $diagnostic_token !== $tokens[ $position ]->id ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported SHOW %s statement.', strtoupper( $diagnostic_name ) ) ); + } + return array( + 'type' => 'count', + 'count_column' => '@@session.' . ( 'warnings' === $diagnostic_name ? 'warning_count' : 'error_count' ), + 'statement' => $diagnostic_name, + ); + } + + if ( WP_MySQL_Lexer::WARNINGS_SYMBOL !== $tokens[ $position ]->id && WP_MySQL_Lexer::ERRORS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( $diagnostic_token !== $tokens[ $position ]->id ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) && ! $this->is_supported_show_diagnostics_limit_clause( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported SHOW %s statement.', strtoupper( $diagnostic_name ) ) ); + } + return array( + 'type' => 'rows', + 'count_column' => null, + 'statement' => $diagnostic_name, + ); + } + private function is_supported_show_diagnostics_limit_clause( array $tokens, int $position ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( + null === $statement_end + || ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + ) { + return false; + } + + return null !== $this->get_mysql_show_limit_clause( $tokens, $position, $statement_end ); + } + private function is_mysql_show_limit_number_token( WP_MySQL_Token $token ): bool { + return $this->is_mysql_unsigned_integer_token( $token ) && ctype_digit( $token->get_value() ); + } + private function get_mysql_show_limit_clause( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + || ! $this->is_mysql_show_limit_number_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + if ( $position + 2 === $end ) { + return array( + 'offset' => 0, + 'count' => (int) $tokens[ $position + 1 ]->get_value(), + ); + } + + if ( + $position + 4 !== $end + || ! isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || ! $this->is_mysql_show_limit_number_token( $tokens[ $position + 3 ] ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position + 2 ]->id ) { + return array( + 'offset' => (int) $tokens[ $position + 1 ]->get_value(), + 'count' => (int) $tokens[ $position + 3 ]->get_value(), + ); + } + + if ( WP_MySQL_Lexer::OFFSET_SYMBOL === $tokens[ $position + 2 ]->id ) { + return array( + 'offset' => (int) $tokens[ $position + 3 ]->get_value(), + 'count' => (int) $tokens[ $position + 1 ]->get_value(), + ); + } + return null; + } + private function get_show_processlist_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $unsupported_message = 'Unsupported SHOW PROCESSLIST statement.'; + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::GLOBAL_SYMBOL, WP_MySQL_Lexer::SESSION_SYMBOL ), true ) && WP_MySQL_Lexer::PROCESSLIST_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $is_full = WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id; + $position += $is_full ? 1 : 0; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::PROCESSLIST_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position + 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $where_filter = null; + $limit = null; + ++$position; + + if ( $position < $statement_end && WP_MySQL_Lexer::WHERE_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position + 1, $statement_end ); + $where_end = null === $limit_position ? $statement_end : $limit_position; + $where_filter = $this->get_mysql_show_where_expression_filter_until( $tokens, $position, $where_end, $this->get_mysql_show_column_map( $this->get_mysql_show_output_columns( 'processlist' ) ), array( 'Id', 'Time' ) ); + if ( null === $where_filter ) { + throw new InvalidArgumentException( $unsupported_message ); + } + $position = $where_end; + } + + $limit = $position < $statement_end ? $this->get_mysql_show_limit_clause( $tokens, $position, $statement_end ) : null; + if ( $position < $statement_end && null === $limit ) { + throw new InvalidArgumentException( $unsupported_message ); + } + return array( + 'full' => $is_full, + 'where_filter' => $where_filter, + 'limit' => $limit, + ); + } + private function get_mysql_show_optional_schema_name( array $tokens, int &$position, string $unsupported_message ): ?string { + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position ]->id + ) + ) { + return null; + } + + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null, true ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $position += 2; + return $schema_name; + } + private function get_mysql_show_result_filter( + array $tokens, + int $position, + string $like_column, + array $columns, + string $unsupported_message, + array $numeric_columns = array() + ): ?array { + $filter = $this->get_mysql_metadata_show_like_where_clause( $tokens, $position, $this->get_mysql_show_column_map( $columns ), $unsupported_message, $numeric_columns ); + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + if ( null !== $filter['like'] ) { + return $this->get_mysql_show_like_filter( $like_column, $filter['like'] ); + } + return $filter['where']; + } + private function get_mysql_show_output_columns( string $result_type ): array { + return self::MYSQL_SHOW_OUTPUT_COLUMNS[ $result_type ]; + } + private function get_mysql_show_column_map( array $columns ): array { + return $columns ? array_combine( array_map( 'strtolower', $columns ), $columns ) : array(); + } + private function get_mysql_show_where_expression_filter_until( array $tokens, int $position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id || $position + 1 >= $end ) { + return null; + } + + $expression_position = $position + 1; + $predicate = $this->parse_mysql_show_where_or_expression( $tokens, $expression_position, $end, $allowed_columns, $numeric_columns ); + if ( null === $predicate || $expression_position !== $end ) { + return null; + } + return $this->get_mysql_show_where_predicate_filter( + $predicate, + $this->get_mysql_show_where_pushdown_conditions( $predicate, $numeric_columns ) + ); + } + private function is_mysql_show_where_expression_filter( ?array $where_filter ): bool { + return is_array( $where_filter ) && 'where_expression' === ( $where_filter['type'] ?? null ); + } + private function get_mysql_show_where_predicate_filter( array $predicate, array $pushdown_conditions = array() ): array { + $filter = array( + 'type' => 'where_expression', + 'predicate' => $predicate, + ); + return empty( $pushdown_conditions ) ? $filter : $filter + array( 'pushdown_conditions' => $pushdown_conditions ); + } + private function get_mysql_show_like_filter( string $column, ?string $pattern ): ?array { + return null === $pattern ? null : $this->get_mysql_show_where_predicate_filter( $this->get_mysql_show_simple_comparison_predicate( $column, 'like', $pattern ) ); + } + private function get_mysql_show_simple_comparison_predicate( string $column, string $operator, string $value ): array { + $predicate = array( + 'type' => 'comparison', + 'operator' => $operator, + 'left' => array( + 'type' => 'column', + 'column' => $column, + ), + 'right' => array( + 'type' => 'literal', + 'value' => $value, + ), + ); + return '=' === $operator ? $predicate + array( 'string_compare' => true ) : $predicate; + } + private function get_mysql_show_where_pushdown_conditions( array $predicate, array $numeric_columns ): array { + $conditions = $this->get_mysql_show_where_pushdown_condition_list( $predicate, $numeric_columns ); + return null === $conditions ? array() : $conditions; + } + private function get_mysql_show_where_pushdown_condition_list( array $predicate, array $numeric_columns ): ?array { + if ( 'and' === ( $predicate['type'] ?? null ) ) { + if ( ! isset( $predicate['left'], $predicate['right'] ) || ! is_array( $predicate['left'] ) || ! is_array( $predicate['right'] ) ) { + return null; + } + + $left_conditions = $this->get_mysql_show_where_pushdown_condition_list( $predicate['left'], $numeric_columns ); + $right_conditions = $this->get_mysql_show_where_pushdown_condition_list( $predicate['right'], $numeric_columns ); + return null === $left_conditions || null === $right_conditions ? null : array_merge( $left_conditions, $right_conditions ); + } + + $condition = $this->get_mysql_show_where_pushdown_condition( $predicate, $numeric_columns ); + return null === $condition ? null : array( $condition ); + } + private function get_mysql_show_where_pushdown_condition( array $predicate, array $numeric_columns ): ?array { + $operator = $predicate['operator'] ?? null; + if ( 'comparison' !== ( $predicate['type'] ?? null ) || ! in_array( $operator, array( '=', 'like' ), true ) ) { + return null; + } + + $left = $predicate['left'] ?? null; + $right = $predicate['right'] ?? null; + if ( + ! is_array( $left ) + || ! is_array( $right ) + || 'column' !== ( $left['type'] ?? null ) + || ! is_string( $left['column'] ?? null ) + ) { + return null; + } + + $column = $left['column']; + if ( 'like' === $operator ) { + if ( + null !== ( $predicate['escape'] ?? null ) + || 'literal' !== ( $right['type'] ?? null ) + || ! is_string( $right['value'] ?? null ) + || ! $this->is_mysql_show_where_like_pushdown_pattern_safe( $right['value'] ) + ) { + return null; + } + return array( + 'column' => $column, + 'operator' => 'like', + 'value' => $right['value'], + ); + } + + if ( 'literal' === ( $right['type'] ?? null ) && is_string( $right['value'] ?? null ) ) { + return array( + 'column' => $column, + 'operator' => '=', + 'value' => $right['value'], + ); + } + + if ( + 'number' === ( $right['type'] ?? null ) + && is_string( $right['value'] ?? null ) + && in_array( $column, $numeric_columns, true ) + && ctype_digit( $right['value'] ) + ) { + return array( + 'column' => $column, + 'operator' => '=', + 'value' => $right['value'], + ); + } + + return null; + } + private function is_mysql_show_where_like_pushdown_pattern_safe( string $pattern ): bool { + $length = strlen( $pattern ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char ) { + if ( $i + 1 >= $length ) { + return false; + } + ++$i; + continue; + } + + if ( '_' === $char || '%' === $char ) { + return false; + } + } + return true; + } + private function parse_mysql_show_where_or_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + return $this->parse_mysql_show_where_boolean_binary_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns, 'parse_mysql_show_where_and_expression', WP_MySQL_Lexer::OR_SYMBOL, 'or' ); + } + private function parse_mysql_show_where_and_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + return $this->parse_mysql_show_where_boolean_binary_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns, 'parse_mysql_show_where_not_expression', WP_MySQL_Lexer::AND_SYMBOL, 'and' ); + } + private function parse_mysql_show_where_boolean_binary_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns, string $operand_method, int $operator_token_id, string $type ): ?array { + $left = $this->{$operand_method}( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && $position < $end && $operator_token_id === $tokens[ $position ]->id ) { + ++$position; + $right = $this->{$operand_method}( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + + $left = array( + 'type' => $type, + 'left' => $left, + 'right' => $right, + ); + } + return $left; + } + private function parse_mysql_show_where_not_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $expression = $this->parse_mysql_show_where_not_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $expression ) { + return null; + } + return array( + 'type' => 'not', + 'expr' => $expression, + ); + } + return $this->parse_mysql_show_where_boolean_primary( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + } + private function parse_mysql_show_where_boolean_primary( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_close ) { + $continues_as_comparison = false; + if ( isset( $tokens[ $after_close ] ) && $after_close < $end ) { + $token_id = $tokens[ $after_close ]->id; + $continues_as_comparison = isset( self::MYSQL_SHOW_WHERE_COMPARISON_OPERATORS[ $token_id ] ) + || in_array( $token_id, array( WP_MySQL_Lexer::BETWEEN_SYMBOL, WP_MySQL_Lexer::IN_SYMBOL, WP_MySQL_Lexer::IS_SYMBOL, WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::NOT_SYMBOL ), true ); + } + if ( $continues_as_comparison ) { + $left = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + return null === $left ? null : $this->parse_mysql_show_where_comparison_suffix( $tokens, $position, $end, $allowed_columns, $numeric_columns, $left ); + } + + $inner_position = $position + 1; + $predicate = $this->parse_mysql_show_where_or_expression( $tokens, $inner_position, $after_close - 1, $allowed_columns, $numeric_columns ); + if ( null !== $predicate && $inner_position === $after_close - 1 ) { + $position = $after_close; + return $predicate; + } + } + } + $left = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + return null === $left ? null : $this->parse_mysql_show_where_comparison_suffix( $tokens, $position, $end, $allowed_columns, $numeric_columns, $left ); + } + private function parse_mysql_show_where_comparison_suffix( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns, array $left ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + $operator = self::MYSQL_SHOW_WHERE_COMPARISON_OPERATORS[ $tokens[ $position ]->id ] ?? null; + if ( null === $operator ) { + $token_id = $tokens[ $position ]->id; + if ( WP_MySQL_Lexer::IS_SYMBOL === $token_id ) { + ++$position; + $is_not = isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id; + $position += $is_not ? 1 : 0; + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::NULL_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + $type = 'is_null'; + $expr = $left; + $not = $is_not; + return compact( 'type', 'expr', 'not' ); + } + $is_not = WP_MySQL_Lexer::NOT_SYMBOL === $token_id; + $position += $is_not ? 1 : 0; + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return $is_not ? null : array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + $expr = $left; + $not = $is_not; + if ( WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + $operator = $is_not ? 'not_like' : 'like'; + } elseif ( WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $parsed = $this->parse_mysql_show_where_parenthesized_value_arguments( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $parsed || empty( $parsed['arguments'] ) ) { + return null; + } + $position = $parsed['after_close']; + $values = $parsed['arguments']; + $type = 'in'; + return compact( 'type', 'expr', 'values', 'not' ); + } elseif ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $lower = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( + null === $lower + || ! isset( $tokens[ $position ] ) + || $position >= $end + || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id + ) { + return null; + } + + ++$position; + $upper = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $upper ) { + return null; + } + $type = 'between'; + return compact( 'type', 'expr', 'lower', 'upper', 'not' ); + } else { + return $is_not ? null : array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + } + + ++$position; + $right = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + $type = 'comparison'; + $comparison = compact( 'type', 'operator', 'left', 'right' ); + if ( 'like' === $operator || 'not_like' === $operator ) { + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::ESCAPE_SYMBOL !== $tokens[ $position ]->id ) { + $comparison['escape'] = null; + } else { + if ( + ! isset( $tokens[ $position + 1 ] ) + || $position + 1 >= $end + || ! $this->is_mysql_string_literal_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $escape = $tokens[ $position + 1 ]->get_value(); + if ( 1 !== strlen( $escape ) ) { + return null; + } + + $comparison['escape'] = $escape; + $position += 2; + } + } + return $comparison; + } + private function parse_mysql_show_where_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + return $this->parse_mysql_show_where_arithmetic_value_expression( + $tokens, + $position, + $end, + $allowed_columns, + $numeric_columns, + 'parse_mysql_show_where_multiplicative_value_expression', + self::MYSQL_SHOW_WHERE_ADDITIVE_OPERATORS + ); + } + private function parse_mysql_show_where_multiplicative_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + return $this->parse_mysql_show_where_arithmetic_value_expression( + $tokens, + $position, + $end, + $allowed_columns, + $numeric_columns, + 'parse_mysql_show_where_primary_value_expression', + self::MYSQL_SHOW_WHERE_MULTIPLICATIVE_OPERATORS + ); + } + private function parse_mysql_show_where_arithmetic_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns, string $operand_method, array $operators ): ?array { + $left = $this->{$operand_method}( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && $position < $end ) { + $operator = $operators[ $tokens[ $position ]->id ] ?? null; + if ( null === $operator ) { + break; + } + ++$position; + $right = $this->{$operand_method}( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( + null === $right + || ! $this->is_mysql_show_where_numeric_value_expression( $left, $numeric_columns ) + || ! $this->is_mysql_show_where_numeric_value_expression( $right, $numeric_columns ) + ) { + return null; + } + + $left = array( + 'type' => 'arithmetic', + 'operator' => $operator, + 'left' => $left, + 'right' => $right, + ); + } + return $left; + } + private function parse_mysql_show_where_primary_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + $token = $tokens[ $position ]; + if ( WP_MySQL_Lexer::BINARY_SYMBOL === $token->id ) { + ++$position; + $expression = $this->parse_mysql_show_where_primary_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $expression ) { + return null; + } + return array( + 'type' => 'binary', + 'expr' => $expression, + ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $inner_position = $position + 1; + $expression = $this->parse_mysql_show_where_value_expression( $tokens, $inner_position, $after_close - 1, $allowed_columns, $numeric_columns ); + if ( null === $expression || $inner_position !== $after_close - 1 ) { + return null; + } + + $position = $after_close; + return $expression; + } + + $function = $this->parse_mysql_show_where_function_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null !== $function ) { + return $function; + } + + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $token->id && isset( $tokens[ $position + 1 ] ) && $position + 1 < $end && $this->is_mysql_numeric_literal_token( $tokens[ $position + 1 ] ) ) { + $value = '-' . $tokens[ $position + 1 ]->get_value(); + $position += 2; + return array( + 'type' => 'number', + 'value' => $value, + ); + } + + $column = $this->get_mysql_show_output_column_name( $token, $allowed_columns ); + if ( null !== $column ) { + ++$position; + return array( + 'type' => 'column', + 'column' => $column, + ); + } + + if ( $this->is_mysql_string_literal_token( $token ) ) { + ++$position; + return array( + 'type' => 'literal', + 'value' => $token->get_value(), + ); + } + + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id || WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + ++$position; + return array( + 'type' => 'number', + 'value' => WP_MySQL_Lexer::TRUE_SYMBOL === $token->id ? '1' : '0', + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + ++$position; + return array( 'type' => 'null' ); + } + + if ( $this->is_mysql_numeric_literal_token( $token ) ) { + ++$position; + return array( + 'type' => 'number', + 'value' => $token->get_value(), + ); + } + return null; + } + private function is_mysql_show_where_numeric_value_expression( array $expression, array $numeric_columns ): bool { + $type = $expression['type'] ?? null; + if ( in_array( $type, array( 'number', 'literal', 'arithmetic', 'column', 'null' ), true ) ) { + return true; + } + + if ( 'function' === $type ) { + return isset( $this->get_mysql_show_where_function_descriptors()[ $expression['function'] ?? '' ] ); + } + return 'binary' === $type + && isset( $expression['expr'] ) + && is_array( $expression['expr'] ) + && $this->is_mysql_show_where_numeric_value_expression( $expression['expr'], $numeric_columns ); + } + private function parse_mysql_show_where_function_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $function_token = $tokens[ $position ] ?? null; + $function_name = null; + if ( null !== $function_token ) { + $function_name = self::MYSQL_SHOW_WHERE_KEYWORD_FUNCTIONS[ $function_token->id ] ?? $this->get_mysql_identifier_token_value( $function_token ); + } + + $function = null === $function_name + ? null + : $this->get_mysql_show_where_function_descriptors()[ strtolower( $function_name ) ] ?? null; + if ( null === $function ) { + return null; + } + + $parsed = $this->parse_mysql_show_where_parenthesized_value_arguments( $tokens, $position + 1, $end, $allowed_columns, $numeric_columns, ! empty( $function['substring_arguments'] ) ); + if ( null === $parsed ) { + return null; + } + + $arguments = $parsed['arguments']; + if ( ! in_array( count( $arguments ), $function['arity'], true ) ) { + return null; + } + + if ( ! empty( $function['numeric_arguments'] ) ) { + foreach ( $arguments as $argument ) { + if ( ! $this->is_mysql_show_where_numeric_value_expression( $argument, $numeric_columns ) ) { + return null; + } + } + } + + $position = $parsed['after_close']; + return array( + 'type' => 'function', + 'function' => $function['function'], + 'arguments' => $arguments, + ); + } + private function get_mysql_show_where_function_descriptors(): array { + static $descriptors = null; + if ( null !== $descriptors ) { + return $descriptors; + } + + $descriptors = array(); + foreach ( + array( + array( 'lower', array( 1 ), false, false, array( 'lcase', 'lower' ) ), + array( 'upper', array( 1 ), false, false, array( 'ucase', 'upper' ) ), + array( 'left', array( 2 ), false, false, array( 'left' ) ), + array( 'right', array( 2 ), false, false, array( 'right' ) ), + array( 'substring', array( 2, 3 ), false, true, array( 'substr', 'substring', 'mid' ) ), + array( 'length', array( 1 ), false, false, array( 'length' ) ), + array( 'char_length', array( 1 ), false, false, array( 'char_length', 'character_length' ) ), + array( 'mod', array( 2 ), true, false, array( 'mod' ) ), + ) as $definition + ) { + list( $function, $arity, $numeric_arguments, $substring_arguments, $aliases ) = $definition; + $descriptor = array( + 'function' => $function, + 'arity' => $arity, + 'numeric_arguments' => $numeric_arguments, + 'substring_arguments' => $substring_arguments, + ); + foreach ( $aliases as $alias ) { + $descriptors[ $alias ] = $descriptor; + } + } + return $descriptors; + } + private function parse_mysql_show_where_parenthesized_value_arguments( array $tokens, int $position, int $end, array $allowed_columns, array $numeric_columns, bool $substring_arguments = false ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $argument_ranges = $substring_arguments + ? $this->get_mysql_substring_function_arguments( $tokens, $position + 1, $after_close - 1 ) + : $this->split_top_level_mysql_arguments( $tokens, $position + 1, $after_close - 1 ); + if ( null === $argument_ranges ) { + return null; + } + + $arguments = array(); + foreach ( $argument_ranges as $argument_range ) { + $argument_position = $argument_range['start']; + $argument = $this->parse_mysql_show_where_value_expression( $tokens, $argument_position, $argument_range['end'], $allowed_columns, $numeric_columns ); + if ( null === $argument || $argument_position !== $argument_range['end'] ) { + return null; + } + $arguments[] = $argument; + } + return compact( 'arguments', 'after_close' ); + } + 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 && in_array( $token->id, self::MYSQL_SHOW_OUTPUT_COLUMN_KEYWORD_TOKENS, true ) ) { + $column = $token->get_value(); + } + if ( null === $column ) { + return null; + } + + $column_key = strtolower( $column ); + return $allowed_columns[ $column_key ] ?? null; + } + private function get_show_columns_query( string $query ): ?array { + return $this->get_mysql_show_table_metadata_query( $query, 'columns' ); + } + private function get_show_index_query( string $query ): ?array { + return $this->get_mysql_show_table_metadata_query( $query, 'index' ); + } + private function get_mysql_show_table_metadata_query( string $query, string $metadata_type ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $is_columns = 'columns' === $metadata_type; + $unsupported_message = $is_columns ? 'Unsupported SHOW COLUMNS statement.' : 'Unsupported SHOW INDEX statement.'; + $position = 1; + $position += WP_MySQL_Lexer::EXTENDED_SYMBOL === ( $tokens[ $position ]->id ?? null ) ? 1 : 0; + + $is_full = $is_columns && WP_MySQL_Lexer::FULL_SYMBOL === ( $tokens[ $position ]->id ?? null ); + $position += $is_full ? 1 : 0; + + $matched_tokens = $this->match_mysql_show_descriptor_tokens( $tokens, $position, $is_columns ? array( array( WP_MySQL_Lexer::COLUMNS_SYMBOL ), array( WP_MySQL_Lexer::FIELDS_SYMBOL ) ) : array( array( WP_MySQL_Lexer::INDEX_SYMBOL ), array( WP_MySQL_Lexer::INDEXES_SYMBOL ), array( WP_MySQL_Lexer::KEYS_SYMBOL ) ) ); + if ( null === $matched_tokens ) { + return null; + } + + $position += count( $matched_tokens ); + $table_reference = $this->get_mysql_show_table_reference_query_target( $tokens, $position, $unsupported_message ); + $filter = $this->get_mysql_metadata_show_like_where_clause( + $tokens, + $position, + $this->get_mysql_show_column_map( $this->get_mysql_show_output_columns( $is_columns ? ( $is_full ? 'columns_full' : 'columns' ) : 'index' ) ), + $unsupported_message, + $is_columns ? array() : explode( ' ', 'Non_unique Seq_in_index Cardinality Sub_part' ), + $is_columns + ); + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + return array_combine( array( 'schema', 'table', 'full', 'like', 'where' ), array( $table_reference['schema'], $table_reference['table'], $is_full, $filter['like'], $filter['where'] ) ); + } + private function get_mysql_show_table_reference_query_target( array $tokens, int &$position, string $unsupported_message ): array { + if ( ! isset( $tokens[ $position ] ) || ( WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position ]->id ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $schema_name = $table_reference['schema']; + if ( isset( $tokens[ $position ] ) && ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id ) ) { + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $position += 2; + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $schema_name ); + if ( $this->is_postgresql_internal_schema( $schema_name ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + return array_combine( array( 'schema', 'table' ), array( $schema_name, $table_reference['table'] ) ); + } + private function get_mysql_table_administration_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + $operation_index = array_search( $tokens[0]->id, array( WP_MySQL_Lexer::ANALYZE_SYMBOL, WP_MySQL_Lexer::CHECK_SYMBOL, WP_MySQL_Lexer::OPTIMIZE_SYMBOL, WP_MySQL_Lexer::REPAIR_SYMBOL ), true ); + if ( false === $operation_index ) { + return null; + } + $operation = array( 'analyze', 'check', 'optimize', 'repair' )[ $operation_index ]; + $allows_log_modifier = 'check' !== $operation; + + $position = 1; + if ( + $allows_log_modifier + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::NO_WRITE_TO_BINLOG_SYMBOL ), true ) + ) { + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ), true ) + ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables = array(); + ++$position; + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables[] = $table_reference; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + $position = $this->consume_mysql_table_administration_trailing_options( $tokens, $position, $operation ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + return array( + 'operation' => $operation, + 'tables' => $tables, + ); + } + private function consume_mysql_table_administration_trailing_options( array $tokens, int $position, string $operation ): ?int { + $noop_operation_index = array_search( $operation, array( 'check', 'repair' ), true ); + if ( false !== $noop_operation_index ) { + return $this->consume_mysql_table_administration_noop_trailing_options( $tokens, $position, array( array( WP_MySQL_Lexer::QUICK_SYMBOL, WP_MySQL_Lexer::FAST_SYMBOL, WP_MySQL_Lexer::MEDIUM_SYMBOL, WP_MySQL_Lexer::EXTENDED_SYMBOL, WP_MySQL_Lexer::CHANGED_SYMBOL ), array( WP_MySQL_Lexer::QUICK_SYMBOL, WP_MySQL_Lexer::EXTENDED_SYMBOL, WP_MySQL_Lexer::USE_FRM_SYMBOL ) )[ $noop_operation_index ], array( array( array( WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::UPGRADE_SYMBOL ) ), array() )[ $noop_operation_index ] ); + } + + if ( 'analyze' === $operation ) { + return $this->consume_mysql_table_administration_histogram_options( $tokens, $position ); + } + return $position; + } + private function consume_mysql_table_administration_noop_trailing_options( array $tokens, int $position, array $token_ids, array $sequences ): ?int { + while ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + foreach ( $sequences as $sequence ) { + $sequence_position = $this->consume_mysql_table_administration_token_sequence( $tokens, $position, $sequence ); + if ( null !== $sequence_position ) { + $position = $sequence_position; + continue 2; + } + } + if ( isset( $tokens[ $position ] ) && in_array( $tokens[ $position ]->id, $token_ids, true ) ) { + ++$position; + continue; + } + return null; + } + return $position; + } + private function consume_mysql_table_administration_histogram_options( array $tokens, int $position ): ?int { + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return $position; + } + + $histogram_position = $this->consume_mysql_table_administration_token_sequence( $tokens, $position, array( WP_MySQL_Lexer::UPDATE_SYMBOL, WP_MySQL_Lexer::HISTOGRAM_SYMBOL, WP_MySQL_Lexer::ON_SYMBOL ) ); + if ( null !== $histogram_position ) { + $position = $this->consume_mysql_table_administration_histogram_columns( $tokens, $histogram_position ); + if ( null === $position ) { + return null; + } + if ( + WP_MySQL_Lexer::WITH_SYMBOL === ( $tokens[ $position ]->id ?? null ) + && isset( $tokens[ $position + 1 ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::BUCKETS_SYMBOL === ( $tokens[ $position + 2 ]->id ?? null ) + ) { + $position += 3; + } + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return $position; + } + return ( + WP_MySQL_Lexer::USING_SYMBOL === ( $tokens[ $position ]->id ?? null ) + && WP_MySQL_Lexer::DATA_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + && isset( $tokens[ $position + 2 ] ) + && $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 3 ) + ) ? $position + 3 : null; + } + + $histogram_position = $this->consume_mysql_table_administration_token_sequence( $tokens, $position, array( WP_MySQL_Lexer::DROP_SYMBOL, WP_MySQL_Lexer::HISTOGRAM_SYMBOL, WP_MySQL_Lexer::ON_SYMBOL ) ); + return null === $histogram_position ? null : $this->consume_mysql_table_administration_histogram_columns( $tokens, $histogram_position ); + } + private function consume_mysql_table_administration_token_sequence( array $tokens, int $position, array $token_ids ): ?int { + foreach ( $token_ids as $token_id ) { + if ( ! isset( $tokens[ $position ] ) || $token_id !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + return $position; + } + private function consume_mysql_table_administration_histogram_columns( array $tokens, int $position ): ?int { + while ( true ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return null; + } + ++$position; + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return $position; + } + ++$position; + } + } + private function get_mysql_table_administration_table_reference( array $tokens, int &$position, bool $allow_double_quoted = false ): ?array { + $first_identifier = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position ] ?? null, $allow_double_quoted ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + ); + } + + $table_name = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position + 1 ] ?? null, $allow_double_quoted ); + if ( null === $table_name ) { + return null; + } + + $position += 2; + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + ); + } + private function get_mysql_table_reference_identifier_token_value( ?WP_MySQL_Token $token, bool $allow_double_quoted = false ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token, $allow_double_quoted ); + if ( null !== $identifier ) { + return $identifier; + } + + $identifier = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $identifier ) { + return null; + } + return null === $this->get_direct_information_schema_relation_columns( $identifier ) ? null : $identifier; + } + 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; + } + private function get_mysql_lock_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::UNLOCK_SYMBOL === $tokens[0]->id ) { + if ( ! $this->consume_mysql_lock_tables_keyword( $tokens, $position ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + 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 ( ! $this->consume_mysql_lock_tables_keyword( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $tables = array(); + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + $mode = null === $table_reference ? null : $this->consume_mysql_lock_tables_target_mode( $tokens, $position ); + if ( null === $table_reference || null === $mode ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $tables[] = array( + 'schema' => $table_reference['schema'], + 'table' => $table_reference['table'], + 'mode' => $mode, + ); + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + break; + } + ++$position; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + return array( + 'operation' => 'lock', + 'tables' => $tables, + ); + } + private function consume_mysql_lock_tables_keyword( array $tokens, int &$position ): bool { + if ( ! in_array( $tokens[ $position ]->id ?? null, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ), true ) ) { + return false; + } + + ++$position; + return true; + } + private function consume_mysql_lock_tables_target_mode( array $tokens, int &$position ): ?string { + $has_alias_keyword = WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ); + $alias_position = $has_alias_keyword ? $position + 1 : $position; + $alias = $this->get_mysql_identifier_token_value( $tokens[ $alias_position ] ?? null ); + if ( null === $alias ) { + return $has_alias_keyword ? null : $this->consume_mysql_lock_tables_mode( $tokens, $position ); + } + + $position = $alias_position + 1; + return in_array( $tokens[ $position ]->id ?? null, array( WP_MySQL_Lexer::READ_SYMBOL, WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::WRITE_SYMBOL ), true ) ? $this->consume_mysql_lock_tables_mode( $tokens, $position ) : null; + } + private function consume_mysql_lock_tables_mode( array $tokens, int &$position ): ?string { + $token_id = $tokens[ $position ]->id ?? null; + if ( WP_MySQL_Lexer::READ_SYMBOL === $token_id ) { + ++$position; + if ( WP_MySQL_Lexer::LOCAL_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + return 'read'; + } + + if ( WP_MySQL_Lexer::WRITE_SYMBOL === $token_id ) { + ++$position; + return 'write'; + } + + if ( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $token_id && WP_MySQL_Lexer::WRITE_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $position += 2; + return 'write'; + } + return null; + } + private function execute_mysql_lock_tables_query( array $lock_tables_query ): int { + if ( 'unlock' === $lock_tables_query['operation'] ) { + return $this->execute_mysql_admin_noop_query(); + } + + foreach ( $lock_tables_query['tables'] as $table_reference ) { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + $targets_information_schema = ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ); + if ( $targets_information_schema ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + if ( ! $this->mysql_table_administration_table_exists( $requested_schema, $table_name ) ) { + $table_label = ( null === $requested_schema ? $this->db_name : $requested_schema ) . '.' . $table_name; + throw new InvalidArgumentException( sprintf( "Table '%s' doesn't exist", $table_label ) ); + } + } + + return $this->execute_mysql_admin_noop_query(); + } + private function get_mysql_flush_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::FLUSH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::NO_WRITE_TO_BINLOG_SYMBOL ), true ) + ) { + ++$position; + } + + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ), true ) + && $this->is_at_mysql_query_end( $tokens, $position + 1 ) + ) { + return 'tables'; + } + + if ( + isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::PRIVILEGES_SYMBOL === $tokens[ $position ]->id + && $this->is_at_mysql_query_end( $tokens, $position + 1 ) + ) { + return 'privileges'; + } + throw new InvalidArgumentException( 'Unsupported FLUSH statement.' ); + } + private function execute_mysql_admin_noop_query(): int { + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + 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 = ( null === $requested_schema ? $this->db_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 + ); + } + 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 ); + + $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(); + } + private function get_mysql_table_administration_backend_schema( ?string $requested_schema, string $table_name ): string { + $targets_default_schema = null === $requested_schema + || 0 === strcasecmp( $requested_schema, $this->db_name ) + || 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ); + if ( $targets_default_schema ) { + return $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + } + return $this->resolve_mysql_table_schema_for_introspection( $requested_schema, $table_name ); + } + 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 ) { + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + return $this->execute_direct_information_schema_show_columns_query( $table_name, $is_full, $like, $where_filter, $fetch_mode, ...$fetch_mode_args ); + } + + $cached_result = $this->execute_mysql_cached_show_columns_query( $resolved_schema, $table_name, $is_full, $like, $where_filter, $fetch_mode, ...$fetch_mode_args ); + if ( null !== $cached_result ) { + return $cached_result; + } + + return $this->execute_mysql_catalog_projected_show_result( $this->get_mysql_catalog_projected_show_statement_descriptor( 'columns', compact( 'resolved_schema', 'table_name', 'is_full', 'like', 'where_filter', 'fetch_mode', 'fetch_mode_args' ) ), $fetch_mode, ...$fetch_mode_args ); + } + private function execute_mysql_cached_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ): ?array { + $cache_key = $schema_name . "\0" . $table_name; + if ( ! isset( $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ] ) ) { + return null; + } + + $metadata = $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ]; + if ( ! isset( $metadata['columns'], $metadata['indexes'] ) || ! is_array( $metadata['columns'] ) || ! is_array( $metadata['indexes'] ) ) { + return null; + } + + $output_columns = $this->get_mysql_show_output_columns( $is_full ? 'columns_full' : 'columns' ); + $rows = $this->get_mysql_show_columns_rows_from_metadata( + $metadata['columns'], + $metadata['indexes'], + $output_columns + ); + + return $this->execute_mysql_static_show_result_with_filters( + $output_columns, + $rows, + $this->get_mysql_show_like_filter( 'Field', $like ), + $where_filter, + $fetch_mode, + ...$fetch_mode_args + ); + } + private function get_mysql_show_columns_rows_from_metadata( array $columns, array $indexes, array $output_columns ): array { + $column_key_map = $this->get_mysql_show_column_key_map_from_metadata( $indexes ); + $rows = array(); + foreach ( $columns as $column ) { + $column_name = (string) ( $column['column_name'] ?? '' ); + $row = array( + 'Field' => $column_name, + 'Type' => (string) ( $column['column_type'] ?? '' ), + 'Collation' => $column['collation_name'] ?? null, + 'Null' => (string) ( $column['is_nullable'] ?? '' ), + 'Key' => $column_key_map[ strtolower( $column_name ) ] ?? '', + 'Default' => $column['column_default'] ?? null, + 'Extra' => (string) ( $column['extra'] ?? '' ), + 'Privileges' => 'select,insert,update,references', + 'Comment' => (string) ( $column['column_comment'] ?? '' ), + ); + + $rows[] = $this->project_mysql_show_row_columns( $row, $output_columns ); + } + return $rows; + } + private function get_mysql_show_column_key_map_from_metadata( array $indexes ): array { + $priorities = array( + '' => 0, + 'MUL' => 1, + 'UNI' => 2, + 'PRI' => 3, + ); + $keys = array(); + foreach ( $indexes as $index ) { + $column_name = (string) ( $index['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $key_name = strtoupper( (string) ( $index['key_name'] ?? '' ) ); + $key = 'PRIMARY' === $key_name ? 'PRI' : ( '0' === (string) ( $index['non_unique'] ?? '1' ) ? 'UNI' : 'MUL' ); + $map_key = strtolower( $column_name ); + if ( ! isset( $keys[ $map_key ] ) || $priorities[ $key ] > $priorities[ $keys[ $map_key ] ] ) { + $keys[ $map_key ] = $key; + } + } + return $keys; + } + private function project_mysql_show_row_columns( array $row, array $columns ): array { + $projected = array(); + foreach ( $columns as $column ) { + $projected[ $column ] = $row[ $column ] ?? null; + } + return $projected; + } + private function execute_direct_information_schema_show_columns_query( string $table_name, bool $is_full, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $output_columns = $this->get_mysql_show_output_columns( $is_full ? 'columns_full' : 'columns' ); + $descriptors = $this->get_direct_information_schema_column_descriptors( $table_name, $output_columns, $is_full ); + if ( null === $descriptors ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + return $this->execute_mysql_static_show_result_with_filters( + $output_columns, + array_column( $descriptors, 'row' ), + $this->get_mysql_show_like_filter( 'Field', $like ), + $where_filter, + $fetch_mode, + ...$fetch_mode_args + ); + } + private function get_direct_information_schema_column_descriptors( string $table_name, ?array $output_columns = null, bool $is_full = false ): ?array { + $columns = $this->get_direct_information_schema_relation_columns( $table_name ); + if ( null === $columns ) { + return null; + } + $descriptors = array(); + foreach ( $columns as $column ) { + $create_type = $this->get_direct_information_schema_create_column_type( $column ); + $show_type = preg_replace( '/\s+DEFAULT\s+NULL\z/i', '', $create_type ); + $descriptors[] = array_combine( array( 'definition', 'row' ), array( sprintf( ' %s %s', $this->quote_mysql_identifier( $column ), $create_type ), null === $output_columns ? null : array_merge( array_fill_keys( $output_columns, null ), array_combine( explode( ' ', 'Field Type Null Key Extra' ), array( $column, null === $show_type ? 'varchar(512)' : $show_type, 'YES', '', '' ) ), $is_full ? array_combine( array( 'Privileges', 'Comment' ), array( 'select', '' ) ) : array() ) ) ); + } + return $descriptors; + } + private function execute_mysql_static_show_result_with_filters( array $columns, array $rows, ?array $like_filter, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $rows = null === $like_filter ? $rows : $this->filter_mysql_static_show_rows( $rows, $like_filter ); + return $this->execute_mysql_filtered_static_show_result( $columns, $rows, $where_filter, $fetch_mode, ...$fetch_mode_args ); + } + private function get_show_tables_output_columns( string $database_name, bool $is_full ): array { + return $is_full ? array( 'Tables_in_' . $database_name, 'Table_type' ) : array( 'Tables_in_' . $database_name ); + } + private function get_mysql_catalog_projected_show_statement_descriptor( string $type, array $context ): array { + if ( 'columns' === $type ) { + return $this->get_mysql_catalog_projected_show_descriptor( + $this->get_mysql_show_output_columns( $context['is_full'] ? 'columns_full' : 'columns' ), + array( + 'Field' => '"COLUMN_NAME"', + 'Type' => '"COLUMN_TYPE"', + 'Collation' => '"COLLATION_NAME"', + 'Null' => '"IS_NULLABLE"', + 'Key' => '"COLUMN_KEY"', + 'Default' => '"COLUMN_DEFAULT"', + 'Extra' => '"EXTRA"', + 'Privileges' => '"PRIVILEGES"', + 'Comment' => '"COLUMN_COMMENT"', + ), + '(' . $this->get_direct_information_schema_relation_sql( 'columns' ) . ') information_schema_columns', + sprintf( '"TABLE_SCHEMA" = COALESCE(NULLIF(?, %s), %s)' . "\n\t" . 'AND "TABLE_NAME" = ?', $this->connection->quote( 'public' ), $this->connection->quote( $this->main_db_name ) ), + array( $context['resolved_schema'], $context['table_name'] ), + 'ORDER BY "ORDINAL_POSITION"', + $this->get_mysql_key_value_array( 'filter', $context['where_filter'], 'like', $context['like'], 'like_expression', '"COLUMN_NAME"', 'cache_key', $this->get_mysql_introspection_result_cache_key( 'show_columns', $context['fetch_mode'], array( $context['resolved_schema'], $context['table_name'], $context['is_full'], $context['like'], $context['where_filter'], $context['fetch_mode'], $context['fetch_mode_args'] ) ), 'unsupported_message', 'Unsupported SHOW COLUMNS statement.' ) + ); + } + if ( 'tables' === $type ) { + $columns = $this->get_show_tables_output_columns( $context['database_name'], $context['is_full'] ); + list( $schema_column, $table_name_sql, $table_type_sql, $table_type_filter_sql, $from, $schema_param ) = array( '"TABLE_SCHEMA"', '"TABLE_NAME"', '"TABLE_TYPE"', '"TABLE_TYPE"', '(' . $this->get_direct_information_schema_relation_sql( 'tables' ) . ') information_schema_tables', $this->get_direct_information_schema_display_schema( $context['schema_name'] ) ); + $expressions = $this->get_mysql_key_value_array( $columns[0], $table_name_sql, 'Table_type', $table_type_sql ); + return $this->get_mysql_catalog_projected_show_descriptor( $columns, $expressions, $from, sprintf( '%1$s = ?' . "\n\t" . 'AND %2$s IN (\'BASE TABLE\', \'VIEW\')' . "\n\t" . 'AND %3$s NOT LIKE \'__wp_postgresql_\' || \'mysql\_%%\' ESCAPE %4$s', $schema_column, $table_type_filter_sql, $table_name_sql, $this->get_mysql_like_default_escape_sql() ), array( $schema_param ), 'ORDER BY ' . $table_name_sql, $this->get_mysql_key_value_array( 'filter', $context['where_filter'], 'like', $context['like'], 'like_expression', $table_name_sql, 'filter_columns', $expressions, 'cache_key', $this->get_mysql_introspection_result_cache_key( 'show_tables', $context['fetch_mode'], array( $context['schema_name'], $context['database_name'], $context['is_full'], $context['like'], $context['where_filter'], $context['fetch_mode'], $context['fetch_mode_args'] ) ), 'unsupported_message', 'Unsupported SHOW TABLES statement.' ) ); + } + if ( 'table_status' === $type ) { + $columns = $this->get_mysql_show_output_columns( 'table_status' ); + return $this->get_mysql_catalog_projected_show_descriptor( + $columns, + array( + 'Name' => '"TABLE_NAME"', + 'Engine' => '"ENGINE"', + 'Version' => 'CAST("VERSION" AS text)', + 'Row_format' => '"ROW_FORMAT"', + 'Rows' => 'CAST("TABLE_ROWS" AS text)', + 'Avg_row_length' => 'CAST("AVG_ROW_LENGTH" AS text)', + 'Data_length' => 'CAST("DATA_LENGTH" AS text)', + 'Max_data_length' => 'CAST("MAX_DATA_LENGTH" AS text)', + 'Index_length' => 'CAST("INDEX_LENGTH" AS text)', + 'Data_free' => 'CAST("DATA_FREE" AS text)', + 'Auto_increment' => 'CASE WHEN "AUTO_INCREMENT" IS NULL THEN NULL ELSE CAST("AUTO_INCREMENT" AS text) END', + 'Create_time' => '"CREATE_TIME"', + 'Update_time' => '"UPDATE_TIME"', + 'Check_time' => '"CHECK_TIME"', + 'Collation' => '"TABLE_COLLATION"', + 'Checksum' => '"CHECKSUM"', + 'Create_options' => '"CREATE_OPTIONS"', + 'Comment' => '"TABLE_COMMENT"', + ), + '(' . $this->get_direct_information_schema_relation_sql( 'tables' ) . ') information_schema_tables', + "\"TABLE_SCHEMA\" = ?\n\tAND \"TABLE_TYPE\" = ?", + array( $this->get_direct_information_schema_display_schema( $context['show_table_status_query']['schema'] ), 'BASE TABLE' ), + 'ORDER BY "TABLE_NAME"', + $this->get_mysql_key_value_array( 'filter', $context['show_table_status_query']['filter'], 'filter_in_php', true ) + ); + } + if ( 'index' === $type ) { + $columns = $this->get_mysql_show_output_columns( 'index' ); + return $this->get_mysql_catalog_projected_show_descriptor( + $columns, + array_combine( $columns, array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + sprintf( + "(\n\tSELECT\n\t\t%1\$s,\n\t\t\"POSTGRESQL_INDEX_OID\" AS postgresql_index_oid\n\tFROM (%2\$s) information_schema_statistics\n\tWHERE \"TABLE_SCHEMA\" = COALESCE(NULLIF(?, %3\$s), %4\$s)\n\t\tAND \"TABLE_NAME\" = ?\n) AS show_index_rows", + $this->get_mysql_show_projection_sql( + $columns, + array( + 'Table' => '"TABLE_NAME"', + 'Non_unique' => 'CAST("NON_UNIQUE" AS text)', + 'Key_name' => '"INDEX_NAME"', + 'Seq_in_index' => 'CAST("SEQ_IN_INDEX" AS text)', + 'Column_name' => '"COLUMN_NAME"', + 'Collation' => '"COLLATION"', + 'Cardinality' => 'CAST("CARDINALITY" AS text)', + 'Sub_part' => 'CAST("SUB_PART" AS text)', + 'Packed' => '"PACKED"', + 'Null' => '"NULLABLE"', + 'Index_type' => '"INDEX_TYPE"', + 'Comment' => '"COMMENT"', + 'Index_comment' => '"INDEX_COMMENT"', + 'Visible' => '"IS_VISIBLE"', + 'Expression' => '"EXPRESSION"', + ), + "\t\t" + ), + $this->get_direct_information_schema_relation_sql( 'statistics', array( 'include_internal_sort_column' => true ) ), + $this->connection->quote( 'public' ), + $this->connection->quote( $this->main_db_name ) + ), + '', + array( $context['resolved_schema'], $context['table_name'] ), + "ORDER BY\n\t\"Key_name\" = 'PRIMARY' DESC,\n\t\"Non_unique\" = '0' DESC,\n\t\"Index_type\" = 'SPATIAL' DESC,\n\t\"Index_type\" = 'BTREE' DESC,\n\t\"Index_type\" = 'FULLTEXT' DESC,\n\tpostgresql_index_oid,\n\tCAST(\"Seq_in_index\" AS integer)", + $this->get_mysql_key_value_array( 'filter', $context['where_filter'], 'filter_prefix', 'WHERE ', 'cache_key', $this->get_mysql_introspection_result_cache_key( 'show_index', $context['fetch_mode'], array( $context['resolved_schema'], $context['table_name'], $context['where_filter'], $context['fetch_mode'], $context['fetch_mode_args'] ) ), 'unsupported_message', 'Unsupported SHOW INDEX statement.' ) + ); + } + throw new LogicException( 'Unsupported catalog projected SHOW descriptor.' ); + } + private function execute_mysql_catalog_show_result( string $sql, array $params, ?array $where_filter, array $filter_columns, string $filter_prefix, string $order_by_sql, ?string $cache_key, string $unsupported_message, $fetch_mode, ...$fetch_mode_args ) { + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $where_expression_filter = $this->is_mysql_show_where_expression_filter( $where_filter ) ? $where_filter : null; + $pushdown_conditions = $where_expression_filter['pushdown_conditions'] ?? array(); + if ( ! empty( $pushdown_conditions ) ) { + $conditions = array(); + foreach ( $pushdown_conditions as $filter ) { + $conditions[] = $this->get_mysql_catalog_show_pushdown_condition_sql( $filter, $filter_columns, $params, $unsupported_message ); + } + + $sql .= "\n" . $filter_prefix . implode( ' AND ', $conditions ); + $where_expression_filter = null; + } + + $sql .= "\n" . $order_by_sql; + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + if ( null !== $where_expression_filter ) { + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + $rows = $this->filter_mysql_static_show_rows( $rows, $where_expression_filter ); + $this->last_found_rows = count( $rows ); + $this->set_mysql_associative_rows_fetch_result( $rows, $fetch_mode, ...$fetch_mode_args ); + } else { + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->last_found_rows = count( $this->last_result ); + } + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + return $this->last_result; + } + private function get_mysql_catalog_show_pushdown_condition_sql( array $filter, array $filter_columns, array &$params, string $unsupported_message ): string { + if ( ! isset( $filter_columns[ $filter['column'] ] ) ) { + throw new InvalidArgumentException( $unsupported_message ); + } + + $expression = $filter_columns[ $filter['column'] ]; + $value = $filter['value']; + if ( 'like' === ( $filter['operator'] ?? '=' ) ) { + $params[] = $value; + return sprintf( + '%s LIKE %s ESCAPE %s', + $this->get_mysql_show_pushdown_case_fold_sql( $expression ), + $this->get_mysql_show_pushdown_case_fold_sql( '?' ), + $this->get_mysql_like_default_escape_sql() + ); + } + + $params[] = $value; + $params[] = $value; + $params[] = $value; + return sprintf( + 'CASE WHEN %1$s ~ %2$s AND %3$s ~ %2$s THEN CAST(%1$s AS double precision) = CAST(%3$s AS double precision) ELSE %4$s = %5$s END', + $this->get_mysql_show_pushdown_text_sql( $expression ), + $this->connection->quote( $this->get_mysql_show_pushdown_numeric_regex() ), + $this->get_mysql_show_pushdown_text_sql( '?' ), + $this->get_mysql_show_pushdown_case_fold_sql( $expression ), + $this->get_mysql_show_pushdown_case_fold_sql( '?' ) + ); + } + private function get_mysql_show_pushdown_case_fold_sql( string $expression ): string { + return sprintf( + 'translate(%s, %s, %s)', + $this->get_mysql_show_pushdown_text_sql( $expression ), + $this->connection->quote( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ), + $this->connection->quote( 'abcdefghijklmnopqrstuvwxyz' ) + ); + } + private function get_mysql_show_pushdown_text_sql( string $expression ): string { + return sprintf( 'CAST(%s AS text)', $expression ); + } + private function get_mysql_show_pushdown_numeric_regex(): string { + return '^[[:space:]]*[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?[[:space:]]*$'; + } + private function execute_mysql_catalog_projected_show_result( array $descriptor, $fetch_mode, ...$fetch_mode_args ) { + $sql = $this->get_mysql_catalog_projected_show_sql( + $descriptor, + $params, + ! empty( $descriptor['filter_in_php'] ) + ); + + if ( ! empty( $descriptor['filter_in_php'] ) ) { + return $this->execute_mysql_filtered_static_show_result( + $descriptor['columns'], + $this->get_mysql_show_query_rows( $sql, $params ), + $descriptor['filter'] ?? null, + $fetch_mode, + ...$fetch_mode_args + ); + } + + return $this->execute_mysql_catalog_show_result( + $sql, + $params, + $descriptor['filter'] ?? null, + $this->get_direct_information_schema_native_projection_expression_map( $descriptor['filter_columns'] ?? $descriptor['expressions'] ), + $descriptor['filter_prefix'] ?? 'AND ', + $descriptor['order_by'], + $descriptor['cache_key'] ?? null, + $descriptor['unsupported_message'] ?? 'Unsupported SHOW statement.', + $fetch_mode, + ...$fetch_mode_args + ); + } + private function get_mysql_catalog_projected_show_descriptor( array $columns, $expressions, string $from, ?string $where = null, array $params = array(), string $order_by = '', array $extra = array() ): array { + return array_merge( compact( 'columns', 'expressions', 'from', 'where', 'params', 'order_by' ), $extra ); + } + private function get_mysql_catalog_projected_show_rows( array $descriptor ): array { + $sql = $this->get_mysql_catalog_projected_show_sql( $descriptor, $params, true ); + return $this->get_mysql_show_query_rows( $sql, $params ); + } + private function get_mysql_catalog_projected_show_sql( array $descriptor, ?array &$params, bool $include_order_by ): string { + $params = $descriptor['params'] ?? array(); + $sql = 'SELECT +' . $this->get_mysql_show_projection_sql( + $descriptor['columns'], + $this->get_direct_information_schema_native_projection_expression_map( $descriptor['expressions'] ), + $descriptor['indent'] ?? "\t" + ) . ' +FROM ' . $descriptor['from']; + + if ( ! empty( $descriptor['where'] ) ) { + $sql .= "\nWHERE " . $descriptor['where']; + } + + if ( null !== ( $descriptor['like'] ?? null ) ) { + $sql .= "\n" . ( empty( $descriptor['where'] ) ? 'WHERE ' : 'AND ' ) . $descriptor['like_expression'] . ' LIKE ? ESCAPE ' . $this->get_mysql_like_default_escape_sql(); + $params[] = $descriptor['like']; + } + + if ( $include_order_by && ! empty( $descriptor['order_by'] ) ) { + $sql .= "\n" . $descriptor['order_by']; + } + return $sql; + } + private function execute_mysql_use_statement( string $database_name ): int { + $is_main_schema = 0 === strcasecmp( $database_name, $this->main_db_name ) || 0 === strcasecmp( $database_name, 'public' ); + if ( $is_main_schema ) { + return $this->execute_mysql_use_statement_success( $this->main_db_name ); + } + + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + return $this->execute_mysql_use_statement_success( 'information_schema' ); + } + + $can_use_catalog_database = ! $this->is_postgresql_internal_schema( $database_name ) + && $this->mysql_database_exists_in_postgresql_catalog( $database_name ); + if ( $can_use_catalog_database ) { + return $this->execute_mysql_use_statement_success( $database_name ); + } + throw new InvalidArgumentException( 'Unsupported USE statement.' ); + } + private function execute_mysql_use_statement_success( string $database_name ): int { + $this->db_name = $database_name; + $this->clear_mysql_metadata_caches(); + $this->last_result = 0; + return $this->last_result; + } + 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 ) { + $columns = $this->get_show_tables_output_columns( $database_name, $is_full ); + $table_column = $columns[0]; + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + return $this->execute_mysql_information_schema_relation_name_show_result( + $columns, + $table_column, + $is_full ? array( 'Table_type' => 'SYSTEM VIEW' ) : array(), + $like, + $where_filter, + $fetch_mode, + ...$fetch_mode_args + ); + } + + return $this->execute_mysql_catalog_projected_show_result( $this->get_mysql_catalog_projected_show_statement_descriptor( 'tables', compact( 'is_full', 'schema_name', 'database_name', 'like', 'where_filter', 'fetch_mode', 'fetch_mode_args' ) ), $fetch_mode, ...$fetch_mode_args ); + } + private function get_mysql_like_default_escape_sql(): string { + return $this->connection->quote( '\\' ); + } + private function execute_show_table_status_query( array $show_table_status_query, $fetch_mode, ...$fetch_mode_args ) { + $columns = $this->get_mysql_show_output_columns( 'table_status' ); + if ( 0 === strcasecmp( $show_table_status_query['database'], 'information_schema' ) ) { + return $this->execute_mysql_information_schema_relation_name_show_result( + $columns, + 'Name', + array( + 'Create_options' => '', + 'Comment' => 'SYSTEM VIEW', + ), + null, + $show_table_status_query['filter'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + return $this->execute_mysql_catalog_projected_show_result( $this->get_mysql_catalog_projected_show_statement_descriptor( 'table_status', compact( 'show_table_status_query' ) ), $fetch_mode, ...$fetch_mode_args ); + } + private function execute_mysql_information_schema_relation_name_show_result( array $columns, string $name_column, array $values, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->get_mysql_information_schema_relation_name_rows( $columns, $name_column, $values ); + return $this->execute_mysql_static_show_result_with_filters( $columns, $rows, $this->get_mysql_show_like_filter( $name_column, $like ), $where_filter, $fetch_mode, ...$fetch_mode_args ); + } + private function get_mysql_information_schema_relation_name_rows( array $columns, string $name_column, array $values = array() ): array { + $rows = array(); + foreach ( $this->get_direct_information_schema_relation_names() as $relation ) { + $rows[] = array_merge( array_fill_keys( $columns, null ), array( $name_column => strtoupper( $relation ) ), $values ); + } + return $rows; + } + private function execute_show_create_table_query( array $show_create_table_query, $fetch_mode, ...$fetch_mode_args ) { + $table_name = $show_create_table_query['table']; + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( + $show_create_table_query['schema'], + $table_name + ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + $descriptors = $this->get_direct_information_schema_column_descriptors( $table_name ); + if ( null === $descriptors ) { + return $this->set_mysql_static_show_result( array( 'Table', 'Create Table' ), array(), $fetch_mode, ...$fetch_mode_args ); + } + $rows = array( + array( + 'Table' => $table_name, + 'Create Table' => sprintf( + "CREATE TEMPORARY TABLE %s (\n%s\n) ENGINE=MEMORY DEFAULT CHARSET=%s COLLATE=%s", + $this->quote_mysql_identifier( strtolower( $table_name ) ), + implode( ",\n", array_column( $descriptors, 'definition' ) ), + self::DEFAULT_MYSQL_CHARSET, + self::DEFAULT_MYSQL_COLLATION + ), + ), + ); + return $this->set_mysql_static_show_result( array( 'Table', 'Create Table' ), $rows, $fetch_mode, ...$fetch_mode_args ); + } + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_create_table', + $fetch_mode, + array( $resolved_schema, $table_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $metadata = $this->get_show_create_table_metadata( $resolved_schema, $table_name ); + $columns = $metadata['columns']; + if ( empty( $columns ) ) { + return $this->set_mysql_static_show_result( array( 'Table', 'Create Table' ), array(), $fetch_mode, ...$fetch_mode_args ); + } + $create_statement = $this->get_mysql_create_table_statement_from_metadata( + $table_name, + $columns, + $metadata['indexes'], + $metadata['foreign_keys'], + $metadata['checks'], + $metadata['table']['comment'], + $this->is_mysql_temporary_schema_name( $resolved_schema ), + $metadata['table']['collation'] + ); + $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; + } + private function get_show_create_table_metadata( string $schema_name, string $table_name, bool $log_queries = true, bool $include_foreign_keys = true ): array { + if ( $include_foreign_keys ) { + $cache_key = $schema_name . "\0" . $table_name; + if ( isset( $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ] ) ) { + return $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ]; + } + } + + $logged_queries = $this->last_postgresql_queries; + try { + return $this->get_mysql_key_value_array( 'columns', $this->get_show_create_table_metadata_rows( 'columns', $schema_name, $table_name, $log_queries ), 'indexes', $this->get_show_create_table_metadata_rows( 'indexes', $schema_name, $table_name, $log_queries ), 'foreign_keys', $include_foreign_keys ? $this->get_show_create_table_metadata_rows( 'foreign_keys', $schema_name, $table_name, $log_queries ) : array(), 'checks', $this->get_show_create_table_metadata_rows( 'checks', $schema_name, $table_name, $log_queries ), 'table', $this->get_show_create_table_table_metadata( $schema_name, $table_name, $log_queries ) ); + } finally { + if ( ! $log_queries ) { + $this->last_postgresql_queries = $logged_queries; + } + } + } + private function get_postgresql_prefix_index_expression_column_name_sql( string $expression_sql ): string { + return sprintf( + 'NULLIF(REPLACE(COALESCE(SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(][Cc][Aa][Ss][Tt][(]"([^"]+)" [Aa][Ss] text[)], 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(][Cc][Aa][Ss][Tt][(]([A-Za-z_][A-Za-z0-9_$]*) [Aa][Ss] text[)], 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(]"([^"]+)", 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(]([A-Za-z_][A-Za-z0-9_$]*), 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(][(]"([^"]+)"[)]::text, 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(]"([^"]+)"::text, 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(][(]([A-Za-z_][A-Za-z0-9_$]*)[)]::text, 1, [0-9]+[)]$\'), SUBSTRING(%1$s FROM \'^[Ss][Uu][Bb][Ss][Tt][Rr][(]([A-Za-z_][A-Za-z0-9_$]*)::text, 1, [0-9]+[)]$\')), \'""\', \'"\'), \'\')', + $expression_sql + ); + } + private function get_postgresql_non_prefix_index_expression_sql( string $expression_sql ): string { + return sprintf( 'CASE WHEN %1$s IS NULL THEN %2$s ELSE NULL END', $this->get_postgresql_prefix_index_expression_column_name_sql( $expression_sql ), $expression_sql ); + } + private function get_show_create_table_table_metadata( string $schema_name, string $table_name, bool $log_query = true ): array { + $row = $this->get_show_create_table_metadata_rows( 'table', $schema_name, $table_name, $log_query )[0] ?? array(); + return $this->get_mysql_key_value_array( 'comment', (string) ( $row['table_comment'] ?? $row['TABLE_COMMENT'] ?? '' ), 'collation', (string) ( $row['table_collation'] ?? $row['TABLE_COLLATION'] ?? self::DEFAULT_MYSQL_COLLATION ) ); + } + private function get_show_create_table_metadata_descriptor( string $type, string $schema_name, string $table_name ): array { + $display_schema = $this->get_direct_information_schema_display_schema( $schema_name ); + $foreign_join = '(' . $this->get_direct_information_schema_relation_sql( 'key_column_usage' ) . ') kcu +INNER JOIN (' . $this->get_direct_information_schema_relation_sql( 'referential_constraints' ) . ') rc + ON rc."CONSTRAINT_SCHEMA" = kcu."CONSTRAINT_SCHEMA" + AND rc."CONSTRAINT_NAME" = kcu."CONSTRAINT_NAME" + AND rc."TABLE_NAME" = kcu."TABLE_NAME"'; + $descriptors = array( + 'columns' => array( + array( + 'column_name' => '"COLUMN_NAME"', + 'ordinal_position' => '"ORDINAL_POSITION"', + 'column_type' => '"COLUMN_TYPE"', + 'character_set_name' => '"CHARACTER_SET_NAME"', + 'collation_name' => '"COLLATION_NAME"', + 'is_nullable' => '"IS_NULLABLE"', + 'column_default' => '"COLUMN_DEFAULT"', + 'extra' => '"EXTRA"', + 'column_comment' => '"COLUMN_COMMENT"', + ), + '(' . $this->get_direct_information_schema_relation_sql( 'columns' ) . ') columns', + "\"TABLE_SCHEMA\" = ?\n\t\tAND \"TABLE_NAME\" = ?", + array( $display_schema, $table_name ), + 'ORDER BY "ORDINAL_POSITION"', + ), + 'indexes' => array( + array( + 'key_name' => '"INDEX_NAME"', + 'index_ordinal' => '"POSTGRESQL_INDEX_OID"', + 'seq_in_index' => '"SEQ_IN_INDEX"', + 'column_name' => '"COLUMN_NAME"', + 'non_unique' => 'CAST("NON_UNIQUE" AS text)', + 'index_type' => '"INDEX_TYPE"', + 'collation' => '"COLLATION"', + 'sub_part' => '"SUB_PART"', + 'index_comment' => '"INDEX_COMMENT"', + ), + '(' . $this->get_direct_information_schema_relation_sql( 'statistics', array( 'include_show_create_table_sort_columns' => true ) ) . ') statistics', + sprintf( "\"TABLE_SCHEMA\" = COALESCE(NULLIF(?, %s), %s)\n\t\tAND \"TABLE_NAME\" = ?\n\t\tAND \"COLUMN_NAME\" IS NOT NULL", $this->connection->quote( 'public' ), $this->connection->quote( $this->main_db_name ) ), + array( $schema_name, $table_name ), + "ORDER BY\n\t\t\"INDEX_NAME\" = 'PRIMARY' DESC,\n\t\t\"NON_UNIQUE\" = 0 DESC,\n\t\t\"POSTGRESQL_ACCESS_METHOD\" = 'btree' DESC,\n\t\t\"POSTGRESQL_INDEX_OID\",\n\t\t\"SEQ_IN_INDEX\"", + ), + 'foreign_keys' => array( + array( + 'constraint_name' => 'kcu."CONSTRAINT_NAME"', + 'constraint_ordinal' => 'DENSE_RANK() OVER (ORDER BY kcu."CONSTRAINT_NAME")', + 'seq_in_index' => 'kcu."ORDINAL_POSITION"', + 'column_name' => 'kcu."COLUMN_NAME"', + 'referenced_table_schema' => 'kcu."REFERENCED_TABLE_SCHEMA"', + 'referenced_table_name' => 'kcu."REFERENCED_TABLE_NAME"', + 'referenced_column_name' => 'kcu."REFERENCED_COLUMN_NAME"', + 'update_rule' => 'rc."UPDATE_RULE"', + 'delete_rule' => 'rc."DELETE_RULE"', + ), + $foreign_join, + "kcu.\"TABLE_SCHEMA\" = ?\n\t\tAND kcu.\"TABLE_NAME\" = ?\n\t\tAND kcu.\"REFERENCED_TABLE_NAME\" IS NOT NULL", + array( $display_schema, $table_name ), + 'ORDER BY constraint_ordinal, seq_in_index', + ), + 'checks' => array( + array( + 'constraint_name' => 'con.conname', + 'constraint_ordinal' => 'ROW_NUMBER() OVER (ORDER BY con.conname)', + 'check_clause' => $this->get_postgresql_mysql_check_clause_comment_sql( 'pg_catalog.obj_description(con.oid, \'pg_constraint\')', 'pg_catalog.pg_get_expr(con.conbin, con.conrelid)' ), + 'enforced' => $this->get_postgresql_mysql_check_enforced_comment_sql( 'pg_catalog.obj_description(con.oid, \'pg_constraint\')' ), + ), + "pg_catalog.pg_constraint con\nINNER JOIN pg_catalog.pg_class t ON t.oid = con.conrelid\nINNER JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace", + "n.nspname = ?\n\t\tAND t.relname = ?\n\t\tAND t.relkind IN ('r', 'p')\n\t\tAND con.contype = 'c'", + array( $schema_name, $table_name ), + 'ORDER BY constraint_ordinal, constraint_name', + ), + 'table' => array( + array( + 'table_comment' => '"TABLE_COMMENT"', + 'table_collation' => '"TABLE_COLLATION"', + ), + '(' . $this->get_direct_information_schema_relation_sql( 'tables' ) . ') tables', + "\"TABLE_SCHEMA\" = ?\n\t\tAND \"TABLE_NAME\" = ?", + array( $display_schema, $table_name ), + 'LIMIT 1', + ), + ); + if ( ! isset( $descriptors[ $type ] ) ) { + throw new LogicException( 'Unsupported SHOW CREATE TABLE metadata descriptor.' ); + } + return $this->get_mysql_catalog_projected_show_descriptor( array(), ...$descriptors[ $type ] ); + } + private function get_show_create_table_metadata_rows( string $type, string $schema_name, string $table_name, bool $log_query = true ): array { + $descriptor = $this->get_show_create_table_metadata_descriptor( $type, $schema_name, $table_name ); + $descriptor['expressions'] = $this->get_direct_information_schema_native_projection_expression_map( $descriptor['expressions'] ); + $descriptor['columns'] = array_keys( $descriptor['expressions'] ); + $sql = $this->get_mysql_catalog_projected_show_sql( $descriptor, $params, true ); + $stmt = $this->connection->query( $sql, $params ); + if ( $log_query ) { + $this->last_postgresql_queries[] = $this->get_mysql_key_value_array( 'sql', $sql, 'params', $params ); + } + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + private function get_mysql_create_table_statement_from_metadata( string $table_name, array $columns, array $indexes, array $foreign_keys, array $checks, string $table_comment = '', bool $temporary = false, ?string $table_collation = null ): string { + $column_definitions = array(); + foreach ( $columns as $column ) { + $column_definitions[] = $this->get_mysql_create_table_column_definition( $column, $table_collation ); + } + $definitions = array_merge( $column_definitions, array_map( array( $this, 'get_mysql_create_table_index_definition' ), $this->group_show_create_table_metadata_rows( $indexes, 'key_name' ) ), array_map( array( $this, 'get_mysql_create_table_foreign_key_definition' ), $this->group_show_create_table_metadata_rows( $foreign_keys, 'constraint_name' ) ), array_map( array( $this, 'get_mysql_create_table_check_definition' ), $checks ) ); + return $this->get_mysql_create_table_sql( $table_name, $definitions, $columns, $table_comment, $temporary, $table_collation ); + } + private function get_mysql_create_table_column_definition( array $column, ?string $table_collation = null ): string { + $extra = (string) ( $column['extra'] ?? '' ); + $definition = sprintf( ' %s %s', $this->quote_mysql_identifier( (string) $column['column_name'] ), (string) $column['column_type'] ); + $definition .= $this->get_mysql_create_table_column_charset_definition( $column, $table_collation ); + if ( 'NO' === strtoupper( (string) $column['is_nullable'] ) ) { + $definition .= ' NOT NULL'; + } + if ( 1 === preg_match( '/auto_increment/i', $extra ) ) { + $definition .= ' AUTO_INCREMENT'; + } + $default = $column['column_default'] ?? null; + $definition .= null === $default ? ( 'NO' !== strtoupper( (string) $column['is_nullable'] ) ? ' DEFAULT NULL' : '' ) : ( $this->is_mysql_current_timestamp_default_metadata( (string) $default ) ? ' DEFAULT ' . (string) $default : ( $this->mysql_column_extra_has_default_generated( $extra ) ? ' DEFAULT (' . (string) $default . ')' : ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( (string) $default ) ) ); + if ( true === $this->mysql_column_extra_has_on_update_current_timestamp( $extra ) ) { + $definition .= ' ON UPDATE CURRENT_TIMESTAMP'; + } + $comment = (string) ( $column['column_comment'] ?? '' ); + return '' === $comment ? $definition : $definition . ' COMMENT ' . $this->quote_mysql_utf8_string_literal( $comment ); + } + private function get_mysql_create_table_column_charset_definition( array $column, ?string $table_collation ): string { + $column_collation = strtolower( trim( (string) ( $column['collation_name'] ?? '' ) ) ); + if ( '' === $column_collation || strtolower( trim( (string) $table_collation ) ) === $column_collation ) { + return ''; + } + + $charset = strtolower( trim( (string) ( $column['character_set_name'] ?? '' ) ) ); + if ( '' === $charset ) { + $charset = $this->get_mysql_charset_from_collation( $column_collation ); + } + return ( '' === $charset ? '' : ' CHARACTER SET ' . $charset ) . ' COLLATE ' . $column_collation; + } + private function get_mysql_charset_from_collation( string $collation ): string { + $separator = strpos( $collation, '_' ); + return false === $separator ? $collation : substr( $collation, 0, $separator ); + } + private function get_mysql_create_table_index_definition( array $index ): string { + $first = $index[0]; + $key_parts = array(); + foreach ( $index as $column ) { + $key_parts[] = $this->quote_mysql_identifier( (string) $column['column_name'] ) . ( null !== ( $column['sub_part'] ?? null ) ? sprintf( '(%d)', (int) $column['sub_part'] ) : '' ) . ( 'D' === strtoupper( (string) ( $column['collation'] ?? '' ) ) ? ' DESC' : '' ); + } + $key_parts = implode( ', ', $key_parts ); + $comment = (string) ( $first['index_comment'] ?? '' ); + return ( 'PRIMARY' === strtoupper( (string) $first['key_name'] ) ? sprintf( ' PRIMARY KEY (%s)', $key_parts ) : 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'] ), $key_parts ) ) . ( '' === $comment ? '' : ' COMMENT ' . $this->quote_mysql_utf8_string_literal( $comment ) ); + } + private function get_mysql_create_table_foreign_key_definition( array $foreign_key ): string { + $first = $foreign_key[0]; + $delete_rule = strtoupper( (string) $first['delete_rule'] ); + $update_rule = strtoupper( (string) $first['update_rule'] ); + return sprintf( ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)', $this->quote_mysql_identifier( (string) $first['constraint_name'] ), $this->get_mysql_create_table_identifier_list( $foreign_key, 'column_name' ), $this->quote_mysql_identifier( (string) $first['referenced_table_name'] ), $this->get_mysql_create_table_identifier_list( $foreign_key, 'referenced_column_name' ) ) . ( 'NO ACTION' === $delete_rule ? '' : ' ON DELETE ' . $delete_rule ) . ( 'NO ACTION' === $update_rule ? '' : ' ON UPDATE ' . $update_rule ); + } + private function get_mysql_create_table_identifier_list( array $rows, string $column ): string { + return implode( ', ', array_map( array( $this, 'quote_mysql_identifier' ), array_map( 'strval', array_column( $rows, $column ) ) ) ); + } + private function get_mysql_create_table_check_definition( array $check ): string { + return sprintf( ' CONSTRAINT %s CHECK (%s)', $this->quote_mysql_identifier( (string) $check['constraint_name'] ), (string) $check['check_clause'] ) . ( 'NO' === strtoupper( (string) ( $check['enforced'] ?? 'YES' ) ) ? ' /*!80016 NOT ENFORCED */' : '' ); + } + private function get_mysql_create_table_sql( string $table_name, array $definitions, array $columns, string $table_comment, bool $temporary, ?string $table_collation ): string { + $collation = (string) ( null !== $table_collation && '' !== $table_collation ? $table_collation : ( array_values( array_filter( array_column( $columns, 'collation_name' ) ) )[0] ?? $this->collation ) ); + return sprintf( "CREATE %sTABLE %s (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=%s COLLATE=%s", $temporary ? 'TEMPORARY ' : '', $this->quote_mysql_identifier( $table_name ), implode( ",\n", $definitions ), false === strpos( $collation, '_' ) ? $collation : substr( $collation, 0, strpos( $collation, '_' ) ), $collation ) . ( '' === $table_comment ? '' : ' COMMENT=' . $this->quote_mysql_utf8_string_literal( $table_comment ) ); + } + private function is_mysql_temporary_schema_name( string $schema_name ): bool { + return 0 === strcasecmp( $schema_name, 'temp' ) || 0 === strcasecmp( $schema_name, 'pg_temp' ) || 1 === preg_match( '/^pg_temp_[0-9]+$/i', $schema_name ); + } + private function is_mysql_current_timestamp_default_metadata( string $default_value ): bool { + return 1 === preg_match( '/^current_timestamp(?:\((?:[0-6])?\))?$/i', $default_value ); + } + private function group_show_create_table_metadata_rows( array $rows, string $key_column ): array { + $grouped = array(); + foreach ( $rows as $row ) { + $grouped[ (string) $row[ $key_column ] ][] = $row; + } + return array_values( $grouped ); + } + private function quote_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + private function quote_mysql_utf8_string_literal( string $literal ): string { + $backslash = chr( 92 ); + return "'" . strtr( $literal, array_combine( array( "'", $backslash, chr( 0 ), chr( 10 ), chr( 13 ) ), array( "''", $backslash . $backslash, $backslash . '0', $backslash . 'n', $backslash . 'r' ) ) ) . "'"; + } + private function get_mysql_show_query_rows( string $sql, array $params = array() ): array { + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + private function execute_mysql_show_result_descriptor( string $result_type, array $show_query, $fetch_mode, ...$fetch_mode_args ) { + $descriptor = $this->get_mysql_show_result_descriptor( $result_type, $show_query ); + $result = $this->execute_mysql_filtered_static_show_result( + $descriptor['columns'], + $descriptor['rows'], + $descriptor['filter'] ?? null, + $fetch_mode, + ...$fetch_mode_args + ); + if ( ! empty( $descriptor['reset_found_rows'] ) ) { + $this->last_found_rows = 0; + } + return $result; + } + private function get_mysql_show_result_descriptor( string $result_type, array $show_query ): array { + $filter = $show_query['filter'] ?? null; + foreach ( array( 'get_mysql_static_show_result_descriptor', 'get_mysql_dynamic_show_result_descriptor' ) as $descriptor_method ) { + $descriptor = $this->{$descriptor_method}( $result_type, $show_query, $filter ); + if ( null !== $descriptor ) { + return $descriptor; + } + } + throw new LogicException( 'Unsupported SHOW result descriptor.' ); + } + private function get_mysql_dynamic_show_result_descriptor( string $result_type, array $show_query, ?array $filter ): ?array { + $descriptors = array( + 'databases' => array( + 'columns' => 'databases', + 'row_source' => array( + 'relation' => 'schemata', + 'alias' => 's', + 'expressions' => 'Database=s."SCHEMA_NAME"', + 'order_by' => 'ORDER BY "Database"', + ), + ), + 'create_database' => array( + 'columns' => array( 'Database', 'Create Database' ), + 'row_source' => 'get_mysql_show_create_database_rows', + ), + 'plugins' => array( + 'columns' => 'plugins', + 'row_source' => array( + 'relation' => 'plugins', + 'alias' => 'p', + 'expressions' => 'Name=p."PLUGIN_NAME"; Status=p."PLUGIN_STATUS"; Type=p."PLUGIN_TYPE"; Library=p."PLUGIN_LIBRARY"; License=p."PLUGIN_LICENSE"', + 'order_by' => 'ORDER BY p."PLUGIN_NAME"', + 'from_close' => "\t\t", + ), + ), + 'routine_status' => array( + 'columns' => 'routine_status', + 'row_source' => array( + 'relation' => 'routines', + 'alias' => 'r', + 'expressions' => 'Db=r."ROUTINE_SCHEMA"; Name=r."ROUTINE_NAME"; Type=r."ROUTINE_TYPE"; Definer=r."DEFINER"; Modified=r."LAST_ALTERED"; Created=r."CREATED"; Security_type=r."SECURITY_TYPE"; Comment=r."ROUTINE_COMMENT"; character_set_client=r."CHARACTER_SET_CLIENT"; collation_connection=r."COLLATION_CONNECTION"; Database Collation=r."DATABASE_COLLATION"', + 'where' => 'r."ROUTINE_TYPE" = ?', + 'param_key' => 'routine_type', + 'order_by' => 'ORDER BY r."ROUTINE_SCHEMA", r."ROUTINE_NAME"', + 'from_close' => "\t\t\t", + ), + ), + 'triggers' => array( + 'columns' => 'triggers', + 'row_source' => array( + 'relation' => 'triggers', + 'alias' => 't', + 'expressions' => 'Trigger=t."TRIGGER_NAME"; Event=t."EVENT_MANIPULATION"; Table=t."EVENT_OBJECT_TABLE"; Statement=t."ACTION_STATEMENT"; Timing=t."ACTION_TIMING"; Created=t."CREATED"; sql_mode=t."SQL_MODE"; Definer=t."DEFINER"; character_set_client=t."CHARACTER_SET_CLIENT"; collation_connection=t."COLLATION_CONNECTION"; Database Collation=t."DATABASE_COLLATION"', + 'where' => 't."TRIGGER_SCHEMA" = ?', + 'param_key' => 'schema', + 'order_by' => 'ORDER BY t."TRIGGER_NAME"', + 'from_close' => "\t\t", + ), + ), + 'processlist' => array( + 'columns' => 'processlist', + 'row_source' => 'get_mysql_show_processlist_rows', + ), + 'open_tables' => array( + 'columns' => 'open_tables', + 'row_source' => array( + 'expressions' => 'Database=' . $this->get_direct_information_schema_display_schema_sql( 'n.nspname' ) . '; Table=c.relname; In_use=CASE WHEN COALESCE(SUM(CASE WHEN l.granted AND l.pid <> pg_catalog.pg_backend_pid() THEN 1 ELSE 0 END), 0) > 0 THEN \'1\' ELSE \'0\' END; Name_locked=CASE WHEN COALESCE(SUM(CASE WHEN NOT l.granted THEN 1 ELSE 0 END), 0) > 0 THEN \'1\' ELSE \'0\' END', + 'from' => "pg_catalog.pg_class c\nINNER JOIN pg_catalog.pg_namespace n\n\tON n.oid = c.relnamespace\nLEFT JOIN pg_catalog.pg_locks l\n\tON l.relation = c.oid", + 'where' => "n.nspname = ?\n\tAND c.relkind IN ('r', 'p', 'v', 'm', 'f')\nGROUP BY n.nspname, c.relname", + 'param_key' => 'schema', + 'order_by' => 'ORDER BY c.relname', + ), + ), + ); + if ( ! isset( $descriptors[ $result_type ] ) ) { + return null; + } + $descriptor = $descriptors[ $result_type ]; + $columns = $this->get_mysql_show_descriptor_columns( $descriptor['columns'] ); + $row_source = $descriptor['row_source']; + $rows = is_string( $row_source ) + ? $this->{$row_source}( $show_query, $columns ) + : $this->get_mysql_dynamic_show_catalog_rows( $row_source, $columns, $show_query ); + return $this->get_mysql_show_result_descriptor_array( $columns, $rows, is_string( $row_source ) ? null : $filter ); + } + private function get_mysql_dynamic_show_catalog_rows( array $row_source, array $columns, array $show_query ): array { + $relation = $row_source['relation'] ?? null; + $param_key = $row_source['param_key'] ?? null; + $from = null === $relation + ? $row_source['from'] + : "(\n" . $this->get_direct_information_schema_relation_sql( $relation ) . "\n" . ( $row_source['from_close'] ?? "\t" ) . ') ' . $row_source['alias']; + return $this->get_mysql_catalog_projected_show_rows( $this->get_mysql_catalog_projected_show_descriptor( $columns, $row_source['expressions'], $from, $row_source['where'] ?? null, null === $param_key ? array() : array( $show_query[ $param_key ] ), $row_source['order_by'] ?? '' ) ); + } + private function get_mysql_static_show_result_descriptor( string $result_type, array $show_query, ?array $filter ): ?array { + $descriptors = array( + 'variables' => array( + 'columns' => array( array( 'Variable_name', 64 ), 'Value' ), + 'rows' => 'global' === ( $show_query['scope'] ?? 'session' ) ? 'get_mysql_global_variables' : 'get_mysql_session_variables', + 'filter' => $filter, + 'extra' => array( 'reset_found_rows' => true ), + 'row_decoder' => 'name_value', + ), + 'status' => array( + 'columns' => 'name_value', + 'rows' => 'get_mysql_status_variables', + 'filter' => $filter, + 'row_decoder' => 'name_value', + ), + 'diagnostics' => array( + 'columns' => 'count' === ( $show_query['type'] ?? null ) ? array( $show_query['count_column'] ?? null ) : array( 'Level', 'Code', 'Message' ), + 'rows' => 'count' === ( $show_query['type'] ?? null ) ? array( array( $show_query['count_column'] ?? null => '0' ) ) : array(), + 'filter' => null, + ), + 'grants' => array( + 'columns' => array( array( self::MYSQL_SHOW_GRANTS_COLUMN, 4096 ) ), + 'rows' => array( array( self::MYSQL_SHOW_GRANTS_COLUMN => self::MYSQL_SHOW_GRANTS_VALUE ) ), + 'filter' => null, + ), + 'events' => array( + 'columns' => 'events', + 'rows' => array(), + 'filter' => $filter, + ), + 'character_set' => array( + 'columns' => array( 'CHARACTER_SET_NAME', 'DEFAULT_COLLATE_NAME', 'DESCRIPTION', 'MAXLEN' ), + 'rows' => array( + array( 'binary', 'binary', 'Binary pseudo charset', '1' ), + array( 'utf8', 'utf8_general_ci', 'UTF-8 Unicode', '3' ), + array( 'utf8mb4', 'utf8mb4_0900_ai_ci', 'UTF-8 Unicode', '4' ), + ), + 'filter' => $filter, + 'row_decoder' => 'projected', + 'projection' => array( + 'Charset' => 'CHARACTER_SET_NAME', + 'Description' => 'DESCRIPTION', + 'Default collation' => 'DEFAULT_COLLATE_NAME', + 'Maxlen' => 'MAXLEN', + ), + ), + 'collation' => array( + 'columns' => array( 'COLLATION_NAME', 'CHARACTER_SET_NAME', 'ID', 'IS_DEFAULT', 'IS_COMPILED', 'SORTLEN', 'PAD_ATTRIBUTE' ), + 'rows' => array( + array( 'binary', 'binary', '63', 'Yes', 'Yes', '1', 'NO PAD' ), + array( 'utf8_bin', 'utf8', '83', '', 'Yes', '1', 'PAD SPACE' ), + array( 'utf8_general_ci', 'utf8', '33', 'Yes', 'Yes', '1', 'PAD SPACE' ), + array( 'utf8_unicode_ci', 'utf8', '192', '', 'Yes', '8', 'PAD SPACE' ), + array( 'utf8mb4_bin', 'utf8mb4', '46', '', 'Yes', '1', 'PAD SPACE' ), + array( 'utf8mb4_unicode_ci', 'utf8mb4', '224', '', 'Yes', '8', 'PAD SPACE' ), + array( 'utf8mb4_0900_ai_ci', 'utf8mb4', '255', 'Yes', 'Yes', '0', 'NO PAD' ), + ), + 'filter' => $filter, + 'row_decoder' => 'projected', + 'projection' => array( + 'Collation' => 'COLLATION_NAME', + 'Charset' => 'CHARACTER_SET_NAME', + 'Id' => 'ID', + 'Default' => 'IS_DEFAULT', + 'Compiled' => 'IS_COMPILED', + 'Sortlen' => 'SORTLEN', + 'Pad_attribute' => 'PAD_ATTRIBUTE', + ), + ), + 'engines' => array( + 'columns' => array( 'Engine', 'Support', 'Comment', 'Transactions', 'XA', 'Savepoints' ), + 'rows' => array( + array( 'InnoDB', 'DEFAULT', 'Supports transactions, row-level locking, and foreign keys', 'YES', 'YES', 'YES' ), + array( 'MEMORY', 'YES', 'Hash based, stored in memory, useful for temporary tables', 'NO', 'NO', 'NO' ), + array( 'MyISAM', 'YES', 'MyISAM storage engine', 'NO', 'NO', 'NO' ), + ), + 'filter' => $filter, + 'row_decoder' => 'static_rows', + ), + ); + if ( ! isset( $descriptors[ $result_type ] ) ) { + return null; + } + $descriptor = $descriptors[ $result_type ]; + $columns = $descriptor['columns']; + $rows = $descriptor['rows']; + $descriptor_filter = $descriptor['filter'] ?? null; + $extra = $descriptor['extra'] ?? array(); + $row_decoder = $descriptor['row_decoder'] ?? null; + if ( 'name_value' === $row_decoder ) { + $rows = $this->get_mysql_show_name_value_rows( $this->{$rows}() ); + } elseif ( in_array( $row_decoder, array( 'projected', 'static_rows' ), true ) ) { + $rows = $this->get_mysql_static_show_rows( $columns, $rows ); + if ( 'projected' === $row_decoder ) { + $rows = $this->project_mysql_static_show_rows( $rows, $this->get_direct_information_schema_native_projection_expression_map( $descriptor['projection'] ) ); + } + $columns = $result_type; + } + return $this->get_mysql_show_result_descriptor_array( $columns, $rows, $descriptor_filter, $extra ); + } + private function get_mysql_show_descriptor_columns( $columns ): array { + return is_string( $columns ) ? $this->get_mysql_show_output_columns( $columns ) : $columns; + } + private function get_mysql_show_result_descriptor_array( $columns, array $rows, ?array $filter, array $extra = array() ): array { + return array_merge( + array_combine( array( 'columns', 'rows', 'filter' ), array( $this->get_mysql_show_descriptor_columns( $columns ), $rows, $filter ) ), + $extra + ); + } + private function get_mysql_show_create_database_rows( array $show_query, array $columns ): array { + $database = (string) $show_query['database']; + $exists = $this->mysql_database_exists_in_postgresql_catalog( $database ); + return $exists ? array( array_combine( $columns, array( $database, sprintf( 'CREATE DATABASE %s%s DEFAULT CHARACTER SET %s COLLATE %s', ! empty( $show_query['if_not_exists'] ) ? 'IF NOT EXISTS ' : '', $this->quote_mysql_identifier( $database ), $this->charset, $this->collation ) ) ) ) : array(); + } + private function get_mysql_show_processlist_rows( array $show_processlist_query, array $columns ): array { + $rows = $this->get_mysql_catalog_projected_show_rows( + $this->get_mysql_catalog_projected_show_descriptor( $columns, 'Id=p."ID"; User=p."USER"; Host=p."HOST"; db=p."DB"; Command=p."COMMAND"; Time=p."TIME"; State=p."STATE"; Info=p."INFO"', "(\n" . $this->get_direct_information_schema_relation_sql( 'processlist' ) . "\n\t) p", null, array(), 'ORDER BY p."ID"' ) + ); + + foreach ( $rows as $index => $row ) { + $info = (string) ( $row['Info'] ?? '' ); + $rows[ $index ] = array_combine( $columns, array( (string) ( $row['Id'] ?? '' ), (string) ( $row['User'] ?? '' ), (string) ( $row['Host'] ?? '' ), (string) ( $row['db'] ?? '' ), (string) ( $row['Command'] ?? '' ), (string) ( $row['Time'] ?? '0' ), (string) ( $row['State'] ?? '' ), ! $show_processlist_query['full'] && strlen( $info ) > 100 ? substr( $info, 0, 100 ) : $info ) ); + } + + if ( isset( $show_processlist_query['where_filter'] ) && is_array( $show_processlist_query['where_filter'] ) ) { + $rows = $this->filter_mysql_static_show_rows( $rows, $show_processlist_query['where_filter'] ); + } + return isset( $show_processlist_query['limit'] ) && is_array( $show_processlist_query['limit'] ) + ? array_slice( $rows, $show_processlist_query['limit']['offset'], $show_processlist_query['limit']['count'] ) + : $rows; + } + private function execute_mysql_filtered_static_show_result( array $columns, array $rows, ?array $show_filter, $fetch_mode, ...$fetch_mode_args ) { + return $this->set_mysql_static_show_result( $columns, null === $show_filter ? $rows : $this->filter_mysql_static_show_rows( $rows, $show_filter ), $fetch_mode, ...$fetch_mode_args ); + } + private function get_mysql_show_name_value_rows( array $values, array $columns = array( 'Variable_name', 'Value' ) ): array { + return $this->get_mysql_static_show_rows( $columns, array_map( null, array_keys( $values ), array_values( $values ) ) ); + } + private function project_mysql_static_show_rows( array $rows, array $column_map ): array { + return array_map( + static function ( array $row ) use ( $column_map ): array { + $projected = array(); + foreach ( $column_map as $column => $source_column ) { + $projected[ $column ] = $row[ $source_column ]; + } + return $projected; + }, + $rows + ); + } + private function get_mysql_static_show_rows( array $columns, array $rows ): array { + return array_map( + static function ( array $row ) use ( $columns ): array { + return array_combine( $columns, $row ); + }, + $rows + ); + } + private function mysql_database_exists_in_postgresql_catalog( string $database ): bool { + $sql = 'SELECT 1 + FROM (' . $this->get_direct_information_schema_relation_sql( 'schemata' ) . ') s + WHERE s."SCHEMA_NAME" = ? + LIMIT 1'; + $params = array( $database ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + return false !== $stmt->fetchColumn(); + } + private function filter_mysql_static_show_rows( array $rows, array $show_filter ): array { + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_filter ): bool { + return $this->is_mysql_show_where_expression_filter( $show_filter ) + && is_array( $show_filter['predicate'] ?? null ) + && $this->evaluate_mysql_show_where_predicate( $show_filter['predicate'], $row ); + } + ) + ); + } + private function evaluate_mysql_show_where_predicate( array $predicate, array $row ): bool { + switch ( $predicate['type'] ?? null ) { + case 'and': + return $this->evaluate_mysql_show_where_predicate( $predicate['left'], $row ) && $this->evaluate_mysql_show_where_predicate( $predicate['right'], $row ); + + case 'or': + return $this->evaluate_mysql_show_where_predicate( $predicate['left'], $row ) || $this->evaluate_mysql_show_where_predicate( $predicate['right'], $row ); + + case 'not': + return ! $this->evaluate_mysql_show_where_predicate( $predicate['expr'], $row ); + + case 'truthy': + $value = $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ); + if ( null === $value ) { + return false; + } + + if ( is_bool( $value ) ) { + return $value; + } + + if ( is_int( $value ) || is_float( $value ) || ( is_string( $value ) && 'number' === ( $predicate['expr']['type'] ?? null ) ) ) { + return 0.0 !== (float) $value; + } + + if ( is_string( $value ) ) { + return '' !== $value; + } + + return (bool) $value; + + case 'is_null': + $is_null = null === $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ); + return ! empty( $predicate['not'] ) ? ! $is_null : $is_null; + + case 'comparison': + list( $left, $left_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['left'], $row ); + list( $right, $right_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['right'], $row ); + return $this->evaluate_mysql_show_where_comparison( $left, $predicate['operator'] ?? null, $right, $left_is_binary || $right_is_binary, $predicate['escape'] ?? null, ! empty( $predicate['string_compare'] ) ); + + case 'in': + list( $left, $left_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['expr'], $row ); + if ( null === $left ) { + return false; + } + + $has_null = false; + foreach ( $predicate['values'] ?? array() as $value_expression ) { + list( $value, $value_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $value_expression, $row ); + if ( null === $value ) { + $has_null = true; + continue; + } + + if ( $this->evaluate_mysql_show_where_comparison( $left, '=', $value, $left_is_binary || $value_is_binary ) ) { + return empty( $predicate['not'] ); + } + } + return ! empty( $predicate['not'] ) && ! $has_null; + + case 'between': + list( $value, $value_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['expr'], $row ); + list( $lower, $lower_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['lower'], $row ); + list( $upper, $upper_is_binary ) = $this->evaluate_mysql_show_where_value_with_binary_modifier( $predicate['upper'], $row ); + if ( null === $value || null === $lower || null === $upper ) { + return false; + } + + $matches = $this->compare_mysql_show_where_values( $value, $lower, $value_is_binary || $lower_is_binary ) >= 0 + && $this->compare_mysql_show_where_values( $value, $upper, $value_is_binary || $upper_is_binary ) <= 0; + return ! empty( $predicate['not'] ) ? ! $matches : $matches; + } + return false; + } + private function evaluate_mysql_show_where_value_with_binary_modifier( array $expression, array $row ): array { + return array( $this->evaluate_mysql_show_where_value( $expression, $row ), $this->mysql_show_where_value_expression_has_binary_modifier( $expression ) ); + } + private function evaluate_mysql_show_where_value( array $expression, array $row ) { + switch ( $expression['type'] ?? null ) { + case 'column': + $column = $expression['column'] ?? null; + return is_string( $column ) && array_key_exists( $column, $row ) ? $row[ $column ] : null; + + case 'literal': + case 'number': + return $expression['value'] ?? null; + + case 'null': + return null; + + case 'function': + return $this->evaluate_mysql_show_where_function_value( $expression, $row ); + + case 'binary': + return isset( $expression['expr'] ) && is_array( $expression['expr'] ) + ? $this->evaluate_mysql_show_where_value( $expression['expr'], $row ) + : null; + + case 'arithmetic': + $left = $this->evaluate_mysql_show_where_value( $expression['left'], $row ); + $right = $this->evaluate_mysql_show_where_value( $expression['right'], $row ); + return $this->evaluate_mysql_show_where_arithmetic_value( $left, $expression['operator'] ?? null, $right ); + } + return null; + } + private function evaluate_mysql_show_where_arithmetic_value( $left, ?string $operator, $right ): ?float { + $left_number = $this->coerce_mysql_show_where_numeric_value( $left ); + $right_number = $this->coerce_mysql_show_where_numeric_value( $right ); + if ( null === $left_number || null === $right_number ) { + return null; + } + + $results = array( + '+' => $left_number + $right_number, + '-' => $left_number - $right_number, + '*' => $left_number * $right_number, + ); + if ( 0.0 !== $right_number ) { + $results['/'] = $left_number / $right_number; + $results['%'] = fmod( $left_number, $right_number ); + } + return $results[ $operator ] ?? null; + } + private function coerce_mysql_show_where_numeric_value( $value ): ?float { + if ( null === $value ) { + return null; + } + + if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { + return (float) $value; + } + + if ( is_string( $value ) ) { + $value = ltrim( $value ); + if ( preg_match( '/\A[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?/', $value, $matches ) ) { + return (float) $matches[0]; + } + return 0.0; + } + return null; + } + private function evaluate_mysql_show_where_function_value( array $expression, array $row ) { + $function = $expression['function'] ?? null; + $arguments = array(); + foreach ( $expression['arguments'] ?? array() as $argument ) { + $arguments[] = $this->evaluate_mysql_show_where_value( $argument, $row ); + } + + if ( in_array( null, $arguments, true ) ) { + return null; + } + + $unary = array_combine( array( 'lower', 'upper', 'length', 'char_length' ), array( 'strtolower', 'strtoupper', 'strlen', 'strlen' ) ); + if ( isset( $unary[ $function ] ) ) { + return $unary[ $function ]( (string) $arguments[0] ); + } + + switch ( $function ) { + case 'left': + case 'right': + $value = (string) $arguments[0]; + $length = (int) $arguments[1]; + return $length <= 0 ? '' : ( 'left' === $function ? substr( $value, 0, $length ) : substr( $value, -$length ) ); + + case 'substring': + $value = (string) $arguments[0]; + $start = (int) $arguments[1]; + if ( 0 === $start ) { + return ''; + } + + $offset = $start > 0 ? $start - 1 : strlen( $value ) + $start; + $length = isset( $arguments[2] ) ? (int) $arguments[2] : null; + return null === $length ? substr( $value, $offset ) : ( $length <= 0 ? '' : substr( $value, $offset, $length ) ); + + case 'mod': + return $this->evaluate_mysql_show_where_arithmetic_value( $arguments[0], '%', $arguments[1] ); + } + return null; + } + private function evaluate_mysql_show_where_comparison( $left, ?string $operator, $right, bool $binary = false, ?string $escape = null, bool $string_compare = false ): bool { + if ( null === $left || null === $right ) { + return '<=>' === $operator && null === $left && null === $right; + } + + if ( '<=>' === $operator ) { + return 0 === $this->compare_mysql_show_where_values( $left, $right, $binary, $string_compare ); + } + + if ( 'like' === $operator || 'not_like' === $operator ) { + $matches = $this->matches_mysql_like_pattern( (string) $left, (string) $right, $escape, $binary ); + return 'not_like' === $operator ? ! $matches : $matches; + } + + $comparison = $this->compare_mysql_show_where_values( $left, $right, $binary, $string_compare ); + $matches = array( + '=' => 0 === $comparison, + '<>' => 0 !== $comparison, + '>' => $comparison > 0, + '>=' => $comparison >= 0, + '<' => $comparison < 0, + '<=' => $comparison <= 0, + ); + return $matches[ $operator ] ?? false; + } + private function mysql_show_where_value_expression_has_binary_modifier( array $expression ): bool { + if ( 'binary' === ( $expression['type'] ?? null ) ) { + return true; + } + + if ( 'arithmetic' === ( $expression['type'] ?? null ) ) { + foreach ( array( 'left', 'right' ) as $side ) { + if ( isset( $expression[ $side ] ) && is_array( $expression[ $side ] ) && $this->mysql_show_where_value_expression_has_binary_modifier( $expression[ $side ] ) ) { + return true; + } + } + } + return false; + } + private function compare_mysql_show_where_values( $left, $right, bool $binary = false, bool $string_compare = false ): int { + if ( ! $binary && ! $string_compare && is_numeric( $left ) && is_numeric( $right ) ) { + $left_number = (float) $left; + $right_number = (float) $right; + return $left_number <=> $right_number; + } + return $binary ? strcmp( (string) $left, (string) $right ) : strcasecmp( (string) $left, (string) $right ); + } + private function set_mysql_static_show_result( array $columns, array $rows, $fetch_mode, ...$fetch_mode_args ) { + $this->last_found_rows = count( $rows ); + $this->last_column_meta = array(); + foreach ( $columns as $column ) { + $column_name = is_array( $column ) ? $column[0] : $column; + $column_len = is_array( $column ) ? $column[1] : 1024; + + $this->last_column_meta[] = array( + 'name' => $column_name, + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => $column_name, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => $column_len, + 'precision' => 0, + 'native_type' => 'string', + ); + } + $this->last_column_count = count( $this->last_column_meta ); + return $this->set_mysql_associative_rows_fetch_result( $rows, $fetch_mode, ...$fetch_mode_args ); + } + private function set_mysql_associative_rows_fetch_result( array $rows, $fetch_mode, ...$fetch_mode_args ) { + if ( PDO::FETCH_NUM === $fetch_mode ) { + $rows = array_map( 'array_values', $rows ); + } elseif ( PDO::FETCH_ASSOC !== $fetch_mode ) { + $rows = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + } + $this->last_result = $rows; + return $this->last_result; + } + private function matches_mysql_like_pattern( string $value, string $pattern, ?string $escape = null, bool $case_sensitive = false ): bool { + $regex = '/^'; + $length = strlen( $pattern ); + $escape_char = null === $escape ? '\\' : $escape; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '' !== $escape_char && $escape_char === $char && $i + 1 < $length ) { + ++$i; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + $regex .= $case_sensitive ? '$/' : '$/i'; + return 1 === preg_match( $regex, $value ); + } + private function get_mysql_session_variables(): array { + return array_replace( + $this->get_default_mysql_session_variables(), + array( 'sql_mode' => $this->get_sql_mode() ), + $this->mysql_session_variable_values + ); + } + private function get_default_mysql_session_variables(): array { + return array_replace( + $this->get_default_mysql_system_variable_values(), + $this->get_read_only_mysql_system_variable_values(), + array_merge( + $this->get_mysql_charset_session_variable_values( self::DEFAULT_MYSQL_CHARSET, self::DEFAULT_MYSQL_COLLATION ), + array( 'sql_mode' => implode( ',', self::DEFAULT_MYSQL_SQL_MODES ) ) + ) + ); + } + private function get_mysql_global_variables(): array { + return array_replace( + $this->get_default_mysql_session_variables(), + $this->mysql_global_variable_values + ); + } + private function get_mysql_status_variables(): array { + return array_combine( + explode( ' ', 'Aborted_clients Aborted_connects Bytes_received Bytes_sent Connections Created_tmp_disk_tables Created_tmp_files Created_tmp_tables Handler_read_first Handler_read_key Handler_read_next Handler_read_prev Handler_read_rnd Handler_read_rnd_next Handler_write Open_tables Opened_tables Questions Slow_queries Threads_cached Threads_connected Threads_created Threads_running Uptime' ), + explode( ' ', '0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0' ) + ); + } + private function sync_mysql_charset_session_variables(): void { + $this->mysql_session_variable_values = array_replace( + $this->mysql_session_variable_values, + $this->get_mysql_charset_session_variable_values( $this->charset, $this->collation ) + ); + } + private function get_mysql_charset_session_variable_values( string $charset, string $collation ): array { + return array_replace( + array_fill_keys( self::MYSQL_CHARSET_SESSION_VARIABLES, $charset ), + array_fill_keys( self::MYSQL_COLLATION_SESSION_VARIABLES, $collation ) + ); + } + private function get_mysql_system_variable_value( string $name, ?string $scope = null ): ?string { + $name = strtolower( $name ); + $variables = 'global' === $scope ? $this->get_mysql_global_variables() : $this->get_mysql_session_variables(); + if ( array_key_exists( $name, $variables ) ) { + return $variables[ $name ]; + } + + if ( 'sql_mode' === $name ) { + return $this->get_sql_mode(); + } + + $read_only_variables = $this->get_read_only_mysql_system_variable_values(); + if ( array_key_exists( $name, $read_only_variables ) ) { + return $read_only_variables[ $name ]; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ) ? $defaults[ $name ] : null; + } + 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; + } + private function translate_mysql_variable_reference_to_postgresql( array $tokens, int $position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $value = $this->get_mysql_user_variable_value( + $this->normalize_mysql_user_variable_name( $tokens[ $position ]->get_value() ) + ); + return array( + 'sql' => null === $value ? 'NULL' : $this->connection->quote( $value ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $position, + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $reference_position = $position; + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $reference_position, $display, $scope ); + if ( null === $name ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + + $value = $this->get_mysql_system_variable_value( $name, $scope ); + if ( null === $value ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + return array( + 'sql' => $this->connection->quote( $value ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $reference_position - 1, + ); + } + private function parse_mysql_system_variable_reference( array $tokens, int &$position, ?string &$display = null, ?string &$scope = null ): ?string { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display_parts = array( '@@' ); + $scope = null; + ++$position; + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && in_array( + $tokens[ $position ]->id, + self::MYSQL_SYSTEM_VARIABLE_SCOPE_TOKENS, + true + ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $scope = strtolower( $tokens[ $position ]->get_value() ); + $display_parts[] = $tokens[ $position ]->get_value(); + $display_parts[] = '.'; + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + in_array( $tokens[ $position ]->id, self::MYSQL_INVALID_SYSTEM_VARIABLE_NAME_TOKENS, true ) + || '' === $tokens[ $position ]->get_value() + ) { + return null; + } + + $display_parts[] = $tokens[ $position ]->get_value(); + $name = strtolower( $tokens[ $position++ ]->get_value() ); + $display = implode( '', $display_parts ); + return $name; + } + private function normalize_mysql_user_variable_name( string $name ): string { + return strtolower( ltrim( $name, '@' ) ); + } + private function normalize_mysql_system_variable_assignment_value( string $name, string $value, ?string $scope = null ): ?string { + $normalized_value = strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); + if ( 'group_concat_max_len' === $name && 'global' === $scope ) { + return null; + } + + if ( 'default' === $normalized_value ) { + if ( 'sql_mode' === $name ) { + return 'DEFAULT'; + } + + if ( $this->is_mysql_charset_session_variable( $name ) ) { + return self::DEFAULT_MYSQL_CHARSET; + } + + if ( $this->is_mysql_collation_session_variable( $name ) ) { + return self::DEFAULT_MYSQL_COLLATION; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ) ? $defaults[ $name ] : null; + } + + if ( 'group_concat_max_len' === $name ) { + return preg_match( '/\A[0-9]+\z/', $normalized_value ) ? $normalized_value : null; + } + + if ( in_array( $name, self::MYSQL_BOOLEAN_SYSTEM_VARIABLES, true ) ) { + if ( in_array( $normalized_value, array( '1', 'on', 'true' ), true ) ) { + return '1'; + } + return in_array( $normalized_value, array( '0', 'off', 'false' ), true ) ? '0' : null; + } + + if ( $this->is_mysql_charset_session_variable( $name ) || $this->is_mysql_collation_session_variable( $name ) ) { + return $normalized_value; + } + return $value; + } + 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 ); + } + private function is_mysql_charset_session_variable( string $name ): bool { + return in_array( $name, self::MYSQL_CHARSET_SESSION_VARIABLES, true ); + } + private function is_mysql_collation_session_variable( string $name ): bool { + return in_array( $name, self::MYSQL_COLLATION_SESSION_VARIABLES, true ); + } + private function get_default_mysql_system_variable_values(): array { + return array_combine( + explode( ' ', 'autocommit big_tables default_collation_for_utf8mb4 default_storage_engine end_markers_in_json explicit_defaults_for_timestamp foreign_key_checks group_concat_max_len innodb_lock_wait_timeout interactive_timeout keep_files_on_create lock_wait_timeout log_bin_trust_function_creators max_allowed_packet net_read_timeout net_write_timeout old_alter_table print_identified_with_as_hex pseudo_replica_mode pseudo_slave_mode require_row_format resultset_metadata select_into_disk_sync session_track_gtids session_track_schema session_track_state_change session_track_transaction_info show_create_table_skip_secondary_engine show_create_table_verbosity sql_auto_is_null sql_big_selects sql_buffer_result sql_log_bin sql_notes sql_quote_show_create sql_safe_updates sql_warnings storage_engine time_zone transaction_isolation transaction_read_only tx_isolation tx_read_only unique_checks use_secondary_engine wait_timeout' ), + explode( ' ', '1 0 utf8mb4_0900_ai_ci InnoDB 0 1 1 1024 50 28800 0 31536000 0 67108864 30 60 0 0 0 0 0 FULL 0 OFF 1 0 OFF 0 0 0 1 0 1 1 1 0 0 InnoDB SYSTEM REPEATABLE-READ 0 REPEATABLE-READ 0 1 ON 28800' ) + ); + } + private function get_read_only_mysql_system_variable_values(): array { + return array_combine( + explode( ' ', 'gtid_purged hostname large_files_support log_bin lower_case_table_names port protocol_version server_id socket version version_comment' ), + array( '', 'localhost', 'ON', '0', '0', '3306', '10', '0', '', $this->get_mysql_version_string(), 'MySQL Community Server - GPL' ) + ); + } + 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 ) + ); + } + private function normalize_mysql_charset_name( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + private function execute_show_index_query( string $schema_name, string $table_name, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + $index_columns = $this->get_mysql_show_output_columns( 'index' ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + return $this->set_mysql_static_show_result( $index_columns, array(), $fetch_mode, ...$fetch_mode_args ); + } + + $cached_result = $this->execute_mysql_cached_show_index_query( $resolved_schema, $table_name, $where_filter, $fetch_mode, ...$fetch_mode_args ); + if ( null !== $cached_result ) { + return $cached_result; + } + + return $this->execute_mysql_catalog_projected_show_result( $this->get_mysql_catalog_projected_show_statement_descriptor( 'index', compact( 'resolved_schema', 'table_name', 'where_filter', 'fetch_mode', 'fetch_mode_args' ) ), $fetch_mode, ...$fetch_mode_args ); + } + private function execute_mysql_cached_show_index_query( string $schema_name, string $table_name, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ): ?array { + $cache_key = $schema_name . "\0" . $table_name; + if ( ! isset( $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ] ) ) { + return null; + } + + $metadata = $this->mysql_show_create_table_metadata_introspection_cache[ $cache_key ]; + if ( ! isset( $metadata['columns'], $metadata['indexes'] ) || ! is_array( $metadata['columns'] ) || ! is_array( $metadata['indexes'] ) ) { + return null; + } + + $columns = $this->get_mysql_show_output_columns( 'index' ); + return $this->execute_mysql_filtered_static_show_result( + $columns, + $this->get_mysql_show_index_rows_from_metadata( $table_name, $metadata['indexes'], $metadata['columns'], $columns ), + $where_filter, + $fetch_mode, + ...$fetch_mode_args + ); + } + private function get_mysql_show_index_rows_from_metadata( string $table_name, array $indexes, array $columns, array $output_columns ): array { + $nullable_columns = array(); + foreach ( $columns as $column ) { + $nullable_columns[ strtolower( (string) ( $column['column_name'] ?? '' ) ) ] = 'NO' === strtoupper( (string) ( $column['is_nullable'] ?? '' ) ) ? '' : 'YES'; + } + + $rows = array(); + foreach ( $indexes as $index ) { + $column_name = (string) ( $index['column_name'] ?? '' ); + $sub_part = $index['sub_part'] ?? null; + $row = array( + 'Table' => $table_name, + 'Non_unique' => (string) ( $index['non_unique'] ?? '' ), + 'Key_name' => (string) ( $index['key_name'] ?? '' ), + 'Seq_in_index' => (string) ( $index['seq_in_index'] ?? '' ), + 'Column_name' => $column_name, + 'Collation' => $index['collation'] ?? null, + 'Cardinality' => '0', + 'Sub_part' => $sub_part, + 'Packed' => null, + 'Null' => null === $sub_part ? ( $nullable_columns[ strtolower( $column_name ) ] ?? '' ) : '', + 'Index_type' => (string) ( $index['index_type'] ?? '' ), + 'Comment' => '', + 'Index_comment' => (string) ( $index['index_comment'] ?? '' ), + 'Visible' => 'YES', + 'Expression' => null, + ); + + $rows[] = $this->project_mysql_show_row_columns( $row, $output_columns ); + } + return $rows; + } + private function load_mysql_introspection_result_from_cache( ?string $cache_key ): bool { + if ( null === $cache_key ) { + return false; + } + + if ( ! array_key_exists( $cache_key, $this->mysql_introspection_result_cache ) ) { + return false; + } + + $cached = $this->mysql_introspection_result_cache[ $cache_key ]; + if ( + ! $this->try_copy_mysql_introspection_cache_value( $cached['column_meta'], $column_meta ) + || ! $this->try_copy_mysql_introspection_cache_value( $cached['result'], $result ) + ) { + unset( $this->mysql_introspection_result_cache[ $cache_key ] ); + return false; + } + + $this->last_column_meta = $column_meta; + $this->last_result = $result; + $this->last_found_rows = count( $result ); + return true; + } + 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, + ); + } + 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 ); + } + 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; + } + 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; + } + private function resolve_mysql_table_schema_for_introspection( string $schema_name, string $table_name ): string { + $uses_current_table_resolution = 0 === strcasecmp( $schema_name, 'public' ) + || ( + 0 === strcasecmp( $schema_name, $this->db_name ) + && 0 !== strcasecmp( $schema_name, 'information_schema' ) + && ! $this->is_postgresql_internal_schema( $schema_name ) + ); + if ( ! $uses_current_table_resolution ) { + return $schema_name; + } + + $cache_key = $schema_name . "\0" . $table_name; + if ( isset( $this->mysql_table_schema_introspection_cache[ $cache_key ] ) ) { + $cached_schema = $this->mysql_table_schema_introspection_cache[ $cache_key ]; + if ( + 'public' !== $schema_name + || true !== $this->mysql_has_active_temporary_tables + || $this->is_mysql_temporary_schema_name( $cached_schema ) + ) { + return $cached_schema; + } + } + + if ( 'public' !== $schema_name && true !== $this->mysql_has_active_temporary_tables ) { + $this->mysql_table_schema_introspection_cache[ $cache_key ] = $schema_name; + return $schema_name; + } + + if ( ! $this->mysql_connection_has_active_temporary_tables() ) { + $this->mysql_table_schema_introspection_cache[ $cache_key ] = $schema_name; + return $schema_name; + } + + $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; + } + private function mysql_connection_has_active_temporary_tables(): bool { + if ( null !== $this->mysql_has_active_temporary_tables ) { + return $this->mysql_has_active_temporary_tables; + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM pg_catalog.pg_class c + WHERE c.relnamespace = pg_my_temp_schema() + AND c.relkind IN (\'r\', \'p\') + LIMIT 1' + ); + + $this->mysql_has_active_temporary_tables = false !== $stmt->fetchColumn(); + return $this->mysql_has_active_temporary_tables; + } + private function get_active_temporary_table_schema( string $table_name ): ?string { + $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 ) + ); + + $schema_name = $stmt->fetchColumn(); + return false === $schema_name ? null : (string) $schema_name; + } + private function mark_mysql_temporary_table_created( string $table_name ): void { + $this->mysql_has_active_temporary_tables = true; + $this->forget_mysql_table_schema_introspection_cache( $table_name ); + } + private function forget_mysql_temporary_table( string $table_name ): void { + $this->mysql_has_active_temporary_tables = null; + $this->forget_mysql_table_schema_introspection_cache( $table_name ); + } + private function forget_mysql_table_schema_introspection_cache( string $table_name ): void { + $cache_key_suffix = "\0" . $table_name; + foreach ( array_keys( $this->mysql_table_schema_introspection_cache ) as $cache_key ) { + if ( substr( $cache_key, -strlen( $cache_key_suffix ) ) === $cache_key_suffix ) { + unset( $this->mysql_table_schema_introspection_cache[ $cache_key ] ); + } + } + } + private function get_mysql_show_projection_sql( array $columns, array $expressions, string $indent ): string { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = $expressions[ $column ] . ' AS ' . $this->connection->quote_identifier( $column ); + } + return implode( ',' . "\n" . $indent, $fields ); + } + private function get_postgresql_catalog_index_columns_cte_sql( string $extra_select_sql = '', string $extra_join_sql = '', array $extra_where_conditions = array() ): string { + $select_sql = '' === $extra_select_sql ? '' : "\t\t" . $extra_select_sql . ",\n"; + $join_sql = '' === $extra_join_sql ? '' : "\n" . $extra_join_sql; + $where_conditions = array_merge( + $extra_where_conditions, + array( + 'k.ordinality <= i.indnkeyatts', + 'i.indisvalid', + 'i.indislive', + ) + ); + return 'index_columns AS ( + SELECT +' . $select_sql . ' 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, + COALESCE(pg_catalog.obj_description(idx.oid, \'pg_class\'), \'\') AS index_comment, + 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, + pg_catalog.pg_index_column_has_property(i.indexrelid, CAST(k.ordinality AS integer), \'desc\') AS is_desc + 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' . $join_sql . ' + WHERE ' . implode( "\n\t\tAND ", $where_conditions ) . ' +)'; + } + + /** + * 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(); + } + + /** + * Commit the current transaction. + */ + public function commit(): void { + $this->connection->get_pdo()->commit(); + $this->connection->reset_statement_savepoint_state(); + } + + /** + * Roll back the current transaction. + */ + public function rollBack(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->connection->get_pdo()->rollBack(); + $this->connection->reset_statement_savepoint_state(); + } + + private function reset_query_state(): void { + $this->last_result = null; + $this->last_column_meta = array(); + $this->last_column_count = 0; + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + $this->last_mysql_query = null; + $this->last_postgresql_queries = array(); + + $this->mysql_last_insert_id_assignment_value = null; + $this->mysql_last_insert_id_assignment_translation_enabled = false; + } + 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(); + } + 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(); + } + private function parse_mysql_insert_table_header( array $tokens, int &$position, bool &$ignore ): ?array { + $position = 1; + $ignore = false; + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( WP_MySQL_Lexer::IGNORE_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $ignore = true; + ++$position; + } + + if ( WP_MySQL_Lexer::INTO_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + return $this->parse_mysql_insert_like_table_header( $tokens, $position ); + } + private function parse_mysql_replace_table_header( array $tokens, int &$position ): ?array { + $position = 1; + $this->consume_mysql_replace_priority_modifier( $tokens, $position ); + if ( WP_MySQL_Lexer::INTO_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + return $this->parse_mysql_insert_like_table_header( $tokens, $position ); + } + private function parse_mysql_insert_like_table_header( array $tokens, int &$position ): ?array { + $table_reference_start = $position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + return array( + 'table' => $table_name, + 'start' => $table_reference_start, + 'end' => $position, + ); + } + private function is_mysql_values_row_list_keyword_token( ?WP_MySQL_Token $token ): bool { + return null !== $token + && in_array( $token->id, self::MYSQL_DML_VALUES_ROW_LIST_KEYWORD_TOKENS, true ); + } + private function is_mysql_parenthesized_select_source( array $tokens, int $position ): bool { + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) + && WP_MySQL_Lexer::SELECT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ); + } + private function is_mysql_dml_select_source_token( ?WP_MySQL_Token $token ): bool { + return null !== $token + && in_array( $token->id, array( WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::WITH_SYMBOL, WP_MySQL_Lexer::OPEN_PAR_SYMBOL ), true ); + } + private function get_mysql_dml_all_columns( string $table_name, ?array &$column_metadata = null ): ?array { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + return $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + } + private function get_mysql_dml_column_lookup( array $columns ): array { + $lookup = array(); + foreach ( $columns as $column ) { + $lookup[ strtolower( (string) $column ) ] = $column; + } + return $lookup; + } + private function parse_mysql_dml_values_after_keyword( array $tokens, int &$position, int $end, array $columns, array &$probe_safe_rows, array &$value_range_rows, bool $require_query_end = true ): ?array { + if ( ! $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + return null; + } + + ++$position; + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $end, count( $columns ), $probe_safe_rows, $value_range_rows ); + if ( null === $value_rows || ( $require_query_end && ! $this->is_at_mysql_query_end( $tokens, $position ) ) ) { + return null; + } + return $value_rows; + } + private function parse_mysql_insert_like_dml_source( string $table_name, array $tokens, int &$position, int $source_end, ?array &$column_metadata, array $options = array() ): ?array { + $allow_select_source = $options['allow_select_source'] ?? true; + $allow_values_alias = ! empty( $options['allow_values_alias'] ); + $value_rows = null; + $value_range_rows = array(); + $probe_safe_rows = array(); + $insert_column_list = false; + $source_aliases = array(); + + if ( + WP_MySQL_Lexer::SELECT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || $this->is_mysql_parenthesized_select_source( $tokens, $position ) + ) { + if ( ! $allow_select_source ) { + return null; + } + + $columns = $this->get_mysql_dml_all_columns( $table_name, $column_metadata ); + $insert_column_list = true; + } elseif ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + } elseif ( $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + $columns = $this->get_mysql_dml_all_columns( $table_name, $column_metadata ); + } elseif ( WP_MySQL_Lexer::SET_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $set_end = $allow_values_alias + ? $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $position, $source_end ) ?? $source_end + : $source_end; + $set_assignments = $this->parse_simple_mysql_insert_set_assignments( $table_name, $tokens, $position, $set_end ); + if ( null === $set_assignments ) { + return null; + } + + $columns = $set_assignments['columns']; + $value_rows = array( $set_assignments['values'] ); + $value_range_rows = array( $set_assignments['ranges'] ); + $probe_safe_rows = array( $set_assignments['probe_safe_values'] ); + $position = $set_end; + } else { + return null; + } + + if ( null === $columns ) { + return null; + } + + $token = $tokens[ $position ] ?? null; + if ( null !== $token && in_array( $token->id, self::MYSQL_DML_SELECT_SOURCE_TOKENS, true ) ) { + return $allow_select_source + ? $this->get_mysql_insert_like_dml_source_parse_result( $columns, null, array(), array(), array(), $insert_column_list ) + : null; + } + + if ( null === $value_rows ) { + $value_rows = $this->parse_mysql_dml_values_after_keyword( + $tokens, + $position, + $source_end, + $columns, + $probe_safe_rows, + $value_range_rows, + ! $allow_values_alias + ); + if ( null === $value_rows ) { + return null; + } + } + + if ( $allow_values_alias ) { + $source_aliases = $this->parse_mysql_upsert_values_alias_clause( $tokens, $position, $source_end, $columns ); + if ( null === $source_aliases ) { + return null; + } + } + + return $this->get_mysql_insert_like_dml_source_parse_result( $columns, $value_rows, $value_range_rows, $probe_safe_rows, $source_aliases, $insert_column_list ); + } + private function get_mysql_insert_like_dml_source_parse_result( array $columns, ?array $value_rows, array $value_range_rows, array $probe_safe_rows, array $source_aliases, bool $insert_column_list ): array { + return array( + 'columns' => $columns, + 'value_rows' => $value_rows, + 'value_range_rows' => $value_range_rows, + 'probe_safe_rows' => $probe_safe_rows, + 'source_aliases' => $source_aliases, + 'insert_column_list' => $insert_column_list, + ); + } + private function is_mysql_replace_query( string $query, ?array &$query_context = null ): bool { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + return isset( $tokens[0] ) && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[0]->id; + } + private function is_unsupported_mysql_insert_set_query( string $query, ?array &$query_context = null ): bool { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return false; + } + return null !== $this->find_top_level_mysql_query_context_token( + $query_context, + WP_MySQL_Lexer::SET_SYMBOL, + 1, + $statement_end + ); + } + private function is_unsupported_mysql_insert_query( string $query, ?array &$query_context = null ): bool { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return false; + } + return $this->contains_top_level_mysql_query_context_token( + $query_context, + 1, + $statement_end, + array( + WP_MySQL_Lexer::PARTITION_SYMBOL, + WP_MySQL_Lexer::RETURNING_SYMBOL, + ) + ); + } + private function parse_simple_mysql_insert_set_assignments( string $table_name, array $tokens, int $start, int $end ): ?array { + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $start ); + if ( $start >= $end || ( null !== $on_duplicate && $on_duplicate < $end ) ) { + return null; + } + + $assignments = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $assignments || empty( $assignments ) ) { + return null; + } + + $columns = array(); + $values = array(); + $ranges = array(); + $probe_safe = array(); + $column_lookup = array(); + foreach ( $assignments as $assignment ) { + $target = $this->parse_simple_mysql_insert_set_assignment_target( + $table_name, + $tokens, + $assignment['start'], + $assignment['end'] + ); + if ( null === $target ) { + return null; + } + + $column = $target['column']; + $value_start = $target['value_start']; + + $column_key = strtolower( $column ); + if ( isset( $column_lookup[ $column_key ] ) ) { + return null; + } + $column_lookup[ $column_key ] = true; + + $columns[] = $column; + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment['end'] ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $assignment['end'], + ); + $probe_safe[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $assignment['end'] ); + } + return array( + 'columns' => $columns, + 'values' => $values, + 'ranges' => $ranges, + 'probe_safe_values' => $probe_safe, + ); + } + private function parse_simple_mysql_insert_set_assignment_target( string $table_name, array $tokens, int $start, int $end ): ?array { + $first = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $start ] ?? null ); + if ( null === $first ) { + return null; + } + + $position = $start + 1; + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $position ]->id ?? null ) ) { + return $position + 1 < $end + ? array( + 'column' => $first, + 'value_start' => $position + 1, + ) + : null; + } + + if ( WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + + $second = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $second ) { + return null; + } + $position += 2; + + if ( WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $third = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( + null === $third + || 0 !== strcasecmp( $first, $this->main_db_name ) + || 0 !== strcasecmp( $second, $table_name ) + ) { + return null; + } + + $column = $third; + $position += 2; + } else { + if ( ! $this->is_mysql_dml_table_qualifier( $first, $table_name, null ) ) { + return null; + } + + $column = $second; + } + + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR !== ( $tokens[ $position ]->id ?? null ) + || $position + 1 >= $end + ) { + return null; + } + return array( + 'column' => $column, + 'value_start' => $position + 1, + ); + } + private function get_mysql_insert_set_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + $identifier = $this->get_mysql_dml_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + return null !== $token && WP_MySQL_Lexer::NAME_SYMBOL === $token->id + ? $token->get_value() + : null; + } + private function get_mysql_insert_select_rewrite_data( + string $query, + string $table_name, + array $columns, + array $tokens, + int $position, + int $statement_end, + int $table_reference_start, + int $table_reference_end, + bool $insert_column_list, + array $default_columns = array(), + string $default_projection_alias = '', + bool $check_direct_information_schema = true + ): ?array { + $select_columns = $columns; + foreach ( $default_columns as $default_column ) { + $columns[] = $default_column['column']; + } + + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( $tokens, $table_reference_start, $table_reference_end ); + if ( $insert_column_list || ! empty( $default_columns ) ) { + $table_reference_sql .= ' (' . $this->get_postgresql_dml_column_list_sql( $columns ) . ')'; + } + + $select_bounds = $this->get_mysql_optional_parenthesized_select_bounds( $tokens, $position, $statement_end ); + if ( null === $select_bounds ) { + return null; + } + + $select_start = $select_bounds['start']; + $select_end = $select_bounds['end']; + $outer_replacements = array( + array( + 'start' => 0, + 'end' => ! empty( $default_columns ) ? $position : $table_reference_end, + 'sql' => 'INSERT INTO ' . $table_reference_sql, + ), + ); + $closing_replacement = array(); + if ( null !== $select_bounds['opening_replacement'] ) { + $outer_replacements[] = $select_bounds['opening_replacement']; + $closing_replacement[] = $select_bounds['closing_replacement']; + } + + $select_replacements = $this->get_mysql_insert_select_projection_replacements( $table_name, $select_columns, $tokens, $select_start, $select_end ); + if ( null === $select_replacements ) { + return null; + } + + if ( $check_direct_information_schema ) { + $direct_information_schema_select_sql = $this->get_insert_select_direct_information_schema_select_sql( $query, $tokens, $select_start, $select_end ); + if ( null !== $direct_information_schema_select_sql ) { + if ( $this->mysql_replacements_overlap_range( $select_replacements, $select_start, $select_end ) ) { + return null; + } + + $select_replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $direct_information_schema_select_sql, + ); + } elseif ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + } + + if ( ! empty( $default_columns ) ) { + $this->sort_mysql_replacements( $select_replacements ); + $select_sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $select_start, $select_end, $select_replacements ); + $select_replacements = array( + array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $this->append_mysql_select_default_projection_sql( $select_sql, $default_columns, $default_projection_alias ), + ), + ); + } + + $replacements = array_merge( $outer_replacements, $select_replacements, $closing_replacement ); + $this->sort_mysql_replacements( $replacements ); + return array( + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, 0, $statement_end, $replacements ), + 'columns' => $columns, + 'select_start' => $select_start, + 'select_end' => $select_end, + ); + } + private function translate_simple_mysql_insert_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $ignore = false; + $header = $this->parse_mysql_insert_table_header( $tokens, $position, $ignore ); + if ( null === $header ) { + return null; + } + $table_name = $header['table']; + $table_reference_start = $header['start']; + $table_reference_end = $header['end']; + + $insert_column_list = false; + if ( + WP_MySQL_Lexer::SELECT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || $this->is_mysql_parenthesized_select_source( $tokens, $position ) + ) { + $columns = $this->get_mysql_dml_all_columns( $table_name, $column_metadata ); + $insert_column_list = true; + if ( null === $columns ) { + return null; + } + } else { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + } + $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; + } + + $rewrite = $this->get_mysql_insert_select_rewrite_data( + $query, + $table_name, + $columns, + $tokens, + $position, + $statement_end, + $table_reference_start, + $table_reference_end, + $insert_column_list + ); + if ( null === $rewrite ) { + return null; + } + + $sql = $rewrite['sql']; + if ( $ignore ) { + $sql .= ' ON CONFLICT DO NOTHING'; + } + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ); + $literal_value_row = null; + $insert_id_value_rows = null; + $value_rows = null; + $explicit_identity_columns = array(); + if ( + null !== $auto_increment_column + && $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) + ) { + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $rewrite['select_start'], + $rewrite['select_end'] + ); + if ( null === $literal_value_row ) { + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } else { + $value_rows = array( $literal_value_row['values'] ); + $insert_id_value_rows = array( $literal_value_row['insert_id_values'] ); + } + } + return array( + 'action' => 'insert', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'ignore' => $ignore, + 'inserted_new_row' => true, + 'value_rows' => $value_rows, + 'insert_id_value_rows' => $insert_id_value_rows, + 'insert_id_unknown' => ! empty( $explicit_identity_columns ), + 'explicit_identity_columns' => $explicit_identity_columns, + ); + } + private function get_mysql_optional_parenthesized_select_bounds( array $tokens, int $position, int $end ): ?array { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id ) { + return array( + 'start' => $position, + 'end' => $end, + 'opening_replacement' => null, + 'closing_replacement' => null, + ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( + null === $after_close + || $after_close !== $end + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + return array( + 'start' => $position + 1, + 'end' => $end - 1, + 'opening_replacement' => array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ), + 'closing_replacement' => array( + 'start' => $end - 1, + 'end' => $end, + 'sql' => '', + ), + ); + } + private function get_insert_select_direct_information_schema_select_sql( string $query, array $tokens, int $select_start, int $select_end ): ?string { + $select_query = $this->get_mysql_token_range_sql( $query, $tokens, $select_start, $select_end ); + if ( null === $select_query ) { + return null; + } + return $this->translate_direct_information_schema_select_query( $select_query ); + } + private function mysql_select_range_requires_direct_information_schema_rewrite( array $tokens, int $select_start, int $select_end ): bool { + if ( $this->select_references_direct_information_schema_relation( $tokens, $select_start + 1, $select_end ) ) { + return true; + } + return 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->mysql_select_range_has_non_dual_table_reference( $tokens, $select_start, $select_end ); + } + private function mysql_select_range_has_non_dual_table_reference( array $tokens, int $select_start, int $select_end ): bool { + for ( $position = $select_start + 1; $position < $select_end; $position++ ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + $dual_translation = $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $position, $select_end ); + if ( null !== $dual_translation ) { + $position = $dual_translation['position']; + continue; + } + return true; + } + return false; + } + private function mysql_replacements_overlap_range( array $replacements, int $start, int $end ): bool { + foreach ( $replacements as $replacement ) { + if ( max( $start, $replacement['start'] ) < min( $end, $replacement['end'] ) ) { + return true; + } + } + return false; + } + private function get_mysql_insert_select_projection_replacements( string $table_name, array $columns, array $tokens, int $select_start, int $select_end ): ?array { + $clauses = $this->get_mysql_select_clause_positions( $tokens, $select_start + 1, $select_end, false ); + $from_position = $clauses['from_position']; + $projection_end = $from_position ?? $clauses['projection_end']; + + if ( $select_start + 1 >= $projection_end ) { + return array(); + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + if ( empty( $target_metadata ) ) { + return array(); + } + + $scope = null; + $group_items = null; + if ( null !== $from_position ) { + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $clauses['from_end'] ); + + $group_position = $clauses['group_position']; + if ( + null !== $group_position + && isset( $tokens[ $group_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $group_position + 1 ]->id + ) { + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $clauses['group_end'] ); + if ( null === $group_items ) { + return null; + } + } + } + + $replacements = array(); + foreach ( $projection_ranges as $index => $range ) { + $column_key = strtolower( $columns[ $index ] ); + $column_metadata = $target_metadata[ $column_key ] ?? null; + if ( null === $column_metadata ) { + continue; + } + + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $expression_start = $expression_bounds['start']; + $expression_end = $expression_bounds['end']; + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ); + $changed = false; + if ( + null !== $group_items + && ! $this->is_mysql_insert_select_grouped_projection_expression( $tokens, $expression_start, $expression_end, $group_items ) + ) { + $projection_sql = sprintf( 'MIN(%s)', $projection_sql ); + $changed = true; + } + + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $expression_start, + $expression_end, + $projection_sql, + $scope + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + $changed = true; + } + + if ( ! $changed ) { + continue; + } + + $replacements[] = array( + 'start' => $range['start'], + 'end' => $range['end'], + 'sql' => $projection_sql, + ); + } + return $replacements; + } + 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; + } + 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 + ); + } + private function get_mysql_insert_select_projection_sql_for_target_column( string $table_name, array $column_metadata, array $tokens, int $start, int $end, string $projection_sql, ?array $scope ): ?string { + $this->validate_strict_mysql_dml_value_for_column( $column_metadata, $tokens, $start, $end ); + + if ( + $this->is_mysql_auto_increment_column_metadata( $column_metadata ) + ) { + if ( $this->is_mysql_generated_auto_increment_value_sql( $projection_sql ) ) { + return $this->get_mysql_insert_select_auto_increment_generated_value_sql( $table_name, $column_metadata ); + } + + if ( + ! $this->is_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) + && $this->is_mysql_zero_literal_range( $tokens, $start, $end ) + ) { + return $this->get_mysql_insert_select_auto_increment_generated_value_sql( $table_name, $column_metadata ); + } + } + + $target_type = (string) ( $column_metadata['column_type'] ?? '' ); + $reference = null; + if ( null !== $scope ) { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( null !== $reference && $reference['end'] !== $end ) { + $reference = null; + } + } + + $cast_text_expression = $this->is_mysql_text_family_column_type( $target_type ) + && ! $this->is_mysql_string_literal_range( $tokens, $start, $end ) + && ( + null === $scope + || null === $reference + || ! $this->is_mysql_text_family_column_reference( $reference, $scope, false ) + ); + $value_sql = $this->get_mysql_dml_target_value_sql( $column_metadata, $tokens, $start, $end, array(), null, null, null, false, $projection_sql, $cast_text_expression ); + if ( $value_sql !== $projection_sql ) { + return $value_sql; + } + + if ( $this->is_mysql_integer_family_column_type( $target_type ) ) { + if ( null !== $scope && null !== $reference && $this->is_mysql_integer_column_reference( $reference, $scope, false ) ) { + 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 ); + } + return null; + } + private function set_last_insert_id_after_dml_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + $this->last_insert_id = 0; + return; + } + + $inserted_new_row = ! isset( $dml_query['inserted_new_row'] ) || $dml_query['inserted_new_row']; + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( (string) $dml_query['table_name'] ); + if ( empty( $metadata_lookup ) ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + $this->last_insert_id = 0; + return; + } + + if ( ! empty( $dml_query['insert_id_unknown'] ) ) { + $this->last_insert_id = 0; + return; + } + + if ( array_key_exists( 'last_insert_id_on_duplicate_key_update', $dml_query ) ) { + $last_insert_id = $dml_query['last_insert_id_on_duplicate_key_update']; + $this->last_insert_id = is_numeric( $last_insert_id ) ? (int) $last_insert_id : $last_insert_id; + return; + } + + if ( isset( $dml_query['insert_id_value_rows'] ) && is_array( $dml_query['insert_id_value_rows'] ) ) { + $insert_id_value_rows = $dml_query['insert_id_value_rows']; + } elseif ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + $insert_id_value_rows = $dml_query['value_rows']; + } elseif ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + $insert_id_value_rows = array( $dml_query['values'] ); + } else { + $insert_id_value_rows = array(); + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( $auto_increment_column, $dml_query['columns'], $insert_id_value_rows ); + 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(); + } + 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; + } + 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; + } + 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; + } + 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; + } + private function get_mysql_constant_integer_expression_value( array $tokens, int $start, int $end ): ?string { + $value = $this->get_mysql_constant_php_integer_expression_value( $tokens, $start, $end ); + return null !== $value && $value >= 0 ? (string) $value : null; + } + private function parse_mysql_constant_integer_expression( array $tokens, int &$position, int $end, int $minimum_precedence = 1 ): ?int { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + $sign = 1; + while ( + $position < $end + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) + ) { + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $position ]->id ) { + $sign *= -1; + } + ++$position; + } + + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $value = $this->parse_mysql_constant_integer_expression( $tokens, $position, $end ); + if ( + null === $value + || $position >= $end + || ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position ]->id + ) { + return null; + } + ++$position; + $value *= $sign; + if ( ! is_int( $value ) ) { + return null; + } + } else { + if ( ! $this->is_mysql_unsigned_integer_token( $tokens[ $position ] ) ) { + return null; + } + + $value = ltrim( $tokens[ $position ]->get_bytes(), '+' ); + ++$position; + if ( '' === $value || ! ctype_digit( $value ) ) { + return null; + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + $value = '0'; + } + $max = (string) PHP_INT_MAX; + if ( strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) ) { + return null; + } + $value = $sign * (int) $value; + } + + while ( $position < $end && isset( $tokens[ $position ] ) ) { + $operator = $tokens[ $position ]->id; + if ( WP_MySQL_Lexer::MULT_OPERATOR === $operator ) { + $precedence = 2; + } elseif ( in_array( $operator, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) ) { + $precedence = 1; + } else { + break; + } + + if ( $precedence < $minimum_precedence ) { + break; + } + + ++$position; + $right = $this->parse_mysql_constant_integer_expression( $tokens, $position, $end, $precedence + 1 ); + if ( null === $right ) { + return null; + } + + if ( WP_MySQL_Lexer::MULT_OPERATOR === $operator ) { + $value *= $right; + } elseif ( WP_MySQL_Lexer::PLUS_OPERATOR === $operator ) { + $value += $right; + } else { + $value -= $right; + } + + if ( ! is_int( $value ) ) { + return null; + } + } + return $value; + } + 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 ( $this->can_skip_mysql_dml_identity_sequence_repair( $dml_query ) ) { + return; + } + + $explicit_identity_columns = array(); + if ( + isset( $dml_query['explicit_identity_columns'] ) + && is_array( $dml_query['explicit_identity_columns'] ) + ) { + foreach ( $dml_query['explicit_identity_columns'] as $column => $explicit ) { + if ( $explicit ) { + $explicit_identity_columns[ strtolower( (string) $column ) ] = true; + } + } + } + + if ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup_from_rows( + $dml_query['columns'], + $dml_query['value_rows'] + ) + $explicit_identity_columns; + } elseif ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup( + $dml_query['columns'], + $dml_query['values'] + ) + $explicit_identity_columns; + } elseif ( empty( $explicit_identity_columns ) ) { + return; + } + + if ( empty( $explicit_identity_columns ) ) { + return; + } + + $table_name = (string) $dml_query['table_name']; + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $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 + ); + } + } + private function can_skip_mysql_dml_identity_sequence_repair( array $dml_query ): bool { + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + return false; + } + + $eligibility = $this->get_mysql_dml_identity_sequence_repair_eligibility( (string) $dml_query['table_name'] ); + if ( null === $eligibility ) { + return false; + } + + if ( empty( $eligibility['has_auto_increment_columns'] ) ) { + return true; + } + + foreach ( $eligibility['auto_increment_columns'] as $column_name ) { + if ( $this->mysql_dml_column_list_contains_column( $dml_query['columns'], $column_name ) ) { + return false; + } + } + + return true; + } + private function get_mysql_dml_identity_sequence_repair_eligibility( string $table_name ): ?array { + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $cache_key = $table_schema . "\0" . $table_name; + if ( array_key_exists( $cache_key, $this->mysql_dml_identity_repair_eligibility_cache ) ) { + return $this->mysql_dml_identity_repair_eligibility_cache[ $cache_key ]; + } + + $metadata = $this->get_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name ); + if ( empty( $metadata ) ) { + return null; + } + + $auto_increment_columns = array(); + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' === $column_name ) { + return null; + } + + if ( $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + $auto_increment_columns[] = $column_name; + } + } + + $eligibility = array( + 'has_auto_increment_columns' => ! empty( $auto_increment_columns ), + 'auto_increment_columns' => $auto_increment_columns, + ); + + $this->mysql_dml_identity_repair_eligibility_cache[ $cache_key ] = $eligibility; + return $eligibility; + } + private function get_explicit_dml_identity_column_lookup( array $columns, array $values ): array { + $explicit_columns = array(); + + foreach ( $columns as $index => $column ) { + $is_explicit_identity_value = false; + if ( isset( $values[ $index ] ) ) { + $value_sql = trim( (string) $values[ $index ] ); + $is_explicit_identity_value = '' !== $value_sql + && ! in_array( strtoupper( $value_sql ), array( 'DEFAULT', 'NULL' ), true ); + } + + if ( ! $is_explicit_identity_value ) { + continue; + } + + $explicit_columns[ strtolower( (string) $column ) ] = true; + } + return $explicit_columns; + } + 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; + } + private function get_dml_identity_column_metadata( string $table_schema, string $table_name ): array { + $column_type = $this->get_direct_information_schema_catalog_column_type_expression( + 'c', + 'pg_catalog.obj_description(seq.oid, \'pg_class\')' + ); + $extra = $this->get_direct_information_schema_column_extra_expression( 'c', true ); + $stmt = $this->connection->query( + sprintf( + 'SELECT + c.column_name, + c.data_type, + c.is_identity, + c.column_default, + %1$s AS mysql_column_type, + %2$s AS mysql_extra, + seq_ns.nspname AS sequence_schema, + seq.relname AS sequence_name + FROM information_schema.columns c + 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', + $column_type, + $extra + ), + array( $table_schema, $table_name ) + ); + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + private function repair_postgresql_identity_sequence( + string $table_schema, + string $table_name, + string $column_name, + string $sequence_schema, + string $sequence_name + ): void { + $sequence_query = $this->get_postgresql_identity_sequence_repair_query( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name + ); + + $this->connection->query( $sequence_query['sql'], $sequence_query['params'] ); + $this->last_postgresql_queries[] = $sequence_query; + } + private function get_postgresql_auto_increment_alter_statements( string $table_schema, string $table_name, string $auto_increment_column, int $minimum_sequence_value ): array { + $metadata = $this->get_dml_identity_column_metadata( $table_schema, $table_name ); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( 0 !== strcasecmp( $column_name, $auto_increment_column ) ) { + continue; + } + + if ( ! $this->is_existing_dbdelta_column_identity( $column_metadata ) ) { + continue; + } + + $sequence_schema = (string) ( $column_metadata['sequence_schema'] ?? '' ); + $sequence_name = (string) ( $column_metadata['sequence_name'] ?? '' ); + if ( '' === $sequence_schema || '' === $sequence_name ) { + continue; + } + + $sequence_query = $this->get_postgresql_identity_sequence_repair_query( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name, + $minimum_sequence_value + ); + return array( + str_replace( + 'CAST(? AS regclass)', + 'CAST(' . $this->connection->quote( $sequence_query['params'][0] ) . ' AS regclass)', + $sequence_query['sql'] + ), + ); + } + return array(); + } + private function get_postgresql_identity_sequence_repair_query( + string $table_schema, + string $table_name, + string $column_name, + string $sequence_schema, + string $sequence_name, + ?int $minimum_sequence_value = null + ): array { + $sequence_identifier = $this->get_postgresql_qualified_identifier( $sequence_schema, $sequence_name ); + $table_value_sql = sprintf( + 'SELECT MAX(%s) AS max_identity_value FROM %s', + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $positive_guard = ''; + if ( null !== $minimum_sequence_value ) { + $table_value_sql = sprintf( + 'SELECT GREATEST(COALESCE(MAX(%s), 0), %d) AS max_identity_value FROM %s', + $this->connection->quote_identifier( $column_name ), + max( 0, $minimum_sequence_value ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $positive_guard = ' + AND table_state.max_identity_value > 0'; + } + return array( + 'sql' => sprintf( + 'WITH sequence_state AS ( + SELECT last_value, is_called FROM %1$s + ), + table_state AS ( + %2$s + ) + SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true) + FROM sequence_state, table_state + WHERE table_state.max_identity_value IS NOT NULL%3$s + AND ( + table_state.max_identity_value > sequence_state.last_value + OR (table_state.max_identity_value = sequence_state.last_value AND NOT sequence_state.is_called) + )', + $sequence_identifier, + $table_value_sql, + $positive_guard + ), + 'params' => array( $sequence_identifier ), + ); + } + 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 ); + } + private function translate_simple_mysql_update_query( string $query, array $cte_names = array() ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $joined_update = $this->translate_mysql_inner_join_update_query( $query, $tokens, $statement_end ); + if ( null !== $joined_update ) { + return $joined_update; + } + + $joined_update = $this->translate_mysql_outer_join_update_query( $tokens, $statement_end ); + if ( null !== $joined_update ) { + return $joined_update; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $statement_end ); + + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $statement_end ); + if ( null === $table_reference ) { + return null; + } + + $table_name = $table_reference['table']; + $alias = $table_reference['alias']; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $clauses = $this->get_mysql_joined_dml_clause_positions( $tokens, $position, $statement_end ); + if ( null === $clauses ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, $position, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name, $alias ); + $update_set_clause = $this->translate_mysql_update_set_clause( $table_name, $alias, $tokens, $position, $clauses['body_end'], $scope, null, false, true ); + if ( null === $update_set_clause ) { + return null; + } + + $table_sql = $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ); + $sql = sprintf( + 'UPDATE %s SET %s', + $table_sql, + $update_set_clause['set_sql'] + ); + + $where_end = $clauses['order'] ?? $clauses['limit'] ?? $statement_end; + $where = $this->translate_simple_mysql_dml_where_sql( $query, $tokens, $clauses['where'], $where_end, $scope, $cte_names ); + $tail = $this->translate_simple_mysql_dml_order_limit_sql( $tokens, $clauses['order'], $clauses['limit'], $statement_end, $scope ); + if ( null === $where || null === $tail ) { + return null; + } + $where_sql = $where['sql']; + + $predicates = array(); + if ( '' !== $tail['order'] || '' !== $tail['limit'] ) { + $subquery_where_sql = null === $where_sql ? '' : ' WHERE ' . $where_sql; + $predicates[] = sprintf( + '%s IN (SELECT %s FROM %s%s%s%s)', + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $table_sql, + $subquery_where_sql, + $tail['order'], + $tail['limit'] + ); + } elseif ( null !== $where_sql ) { + $predicates[] = $where_sql; + } + $predicates[] = $update_set_clause['changed_predicate_sql']; + + if ( count( $predicates ) > 1 ) { + $sql .= ' WHERE (' . implode( ') AND (', $predicates ) . ')'; + } else { + $sql .= ' WHERE ' . $predicates[0]; + } + return $sql; + } + private function translate_mysql_cte_prefixed_update_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + $cte = $this->get_mysql_cte_prefixed_update_data( $query, $tokens ); + if ( null === $cte ) { + return null; + } + + $update_sql = $this->get_mysql_token_range_sql( $query, $tokens, $cte['update_position'], $cte['statement_end'] ); + if ( null === $update_sql ) { + return null; + } + + $translated_update = $this->translate_simple_mysql_update_query( $update_sql, $cte['names'] ); + if ( null === $translated_update ) { + return null; + } + return $cte['sql'] . ' ' . $translated_update; + } + private function get_mysql_cte_prefixed_update_data( string $query, array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::RECURSIVE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $cte_names = array(); + while ( $position < $statement_end ) { + $cte_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $cte_name ) { + return null; + } + $cte_names[ strtolower( $cte_name ) ] = true; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_column_list = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_column_list ) { + return null; + } + $position = $after_column_list; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || ! in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::WITH_SYMBOL ), true ) + ) { + return null; + } + + $after_cte_body = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_cte_body ) { + return null; + } + $position = $after_cte_body; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position ]->id ) { + $cte_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $position ); + if ( null === $this->get_mysql_token_range_sql( $query, $tokens, 0, $position ) ) { + return null; + } + return array( + 'sql' => $cte_sql, + 'names' => $cte_names, + 'update_position' => $position, + 'statement_end' => $statement_end, + ); + } + return null; + } + return null; + } + private function is_mysql_update_ignore_query( string $query, ?array &$query_context = null ): bool { + $tokens = null === $query_context ? $this->get_mysql_tokens( $query ) : $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = null === $query_context ? $this->get_mysql_statement_end_position( $tokens, 1 ) : $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return false; + } + $position = 1; + return $this->consume_mysql_dml_modifiers( $tokens, $position, $statement_end, array( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::IGNORE_SYMBOL ), WP_MySQL_Lexer::IGNORE_SYMBOL ); + } + private function is_mysql_update_ignore_constraint_exception( PDOException $exception ): bool { + $sqlstate = (string) $exception->getCode(); + if ( 0 === strpos( $sqlstate, '23' ) ) { + return true; + } + + $message = strtolower( $exception->getMessage() ); + foreach ( array( 'constraint failed', 'unique constraint', 'not null constraint', 'check constraint', 'foreign key constraint', 'duplicate key value' ) as $needle ) { + if ( false !== strpos( $message, $needle ) ) { + return true; + } + } + return false; + } + private function translate_mysql_outer_join_update_query( array $tokens, int $statement_end ): ?string { + $statement_parts = $this->get_mysql_joined_update_statement_parts( $tokens, $statement_end ); + if ( null === $statement_parts ) { + return null; + } + $set_position = $statement_parts['set_position']; + + $has_left_join = $this->contains_top_level_mysql_token( $tokens, 1, $set_position, array( WP_MySQL_Lexer::LEFT_SYMBOL ) ); + $has_right_join = $this->contains_top_level_mysql_token( $tokens, 1, $set_position, array( WP_MySQL_Lexer::RIGHT_SYMBOL ) ); + if ( + $has_left_join === $has_right_join + || $this->contains_top_level_mysql_token( + $tokens, + 1, + $set_position, + array( + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ) + ) + ) { + return null; + } + + $scope = $this->get_mysql_select_scope( $tokens, 1, $set_position ); + if ( null === $scope || ! empty( $scope['unknown'] ) ) { + return null; + } + + return $this->get_mysql_joined_update_derived_source_update_sql_from_statement( '', $tokens, $statement_end, $statement_parts, $statement_parts['first_reference'], $scope, null, array(), false ); + } + private function get_mysql_joined_update_statement_parts( array $tokens, int $statement_end ): ?array { + $set_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SET_SYMBOL, 1, $statement_end ); + if ( null === $set_position ) { + return null; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $set_position ); + + $source_start = $position; + $first_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $set_position ); + if ( + null === $first_reference + || $position >= $set_position + ) { + return null; + } + + $clauses = $this->get_mysql_joined_dml_clause_positions( $tokens, $set_position + 1, $statement_end ); + if ( null === $clauses || $set_position + 1 >= $clauses['body_end'] ) { + return null; + } + + return array( + 'set_position' => $set_position, + 'source_start' => $source_start, + 'source_position' => $position, + 'first_reference' => $first_reference, + 'clauses' => $clauses, + 'set_end' => $clauses['body_end'], + ); + } + private function get_mysql_joined_dml_where_sql_from_clauses( string $query, array $tokens, array $clauses, int $statement_end, array $scope, ?array $information_schema_context, bool $require_supported_expression ): ?string { + if ( null === $clauses['where'] ) { + return ''; + } + + $where_end = $clauses['order'] ?? $clauses['limit'] ?? $statement_end; + if ( $clauses['where'] + 1 >= $where_end ) { + return null; + } + + return $this->translate_mysql_joined_dml_where_sql( + $query, + $tokens, + $clauses['where'] + 1, + $where_end, + $scope, + $information_schema_context, + $require_supported_expression + ); + } + + private function get_mysql_joined_dml_predicates_from_clauses( string $query, array $tokens, array $clauses, int $statement_end, array $scope, ?array $information_schema_context, bool $require_supported_expression, array $predicates = array() ): ?array { + $where_sql = $this->get_mysql_joined_dml_where_sql_from_clauses( $query, $tokens, $clauses, $statement_end, $scope, $information_schema_context, $require_supported_expression ); + if ( null === $where_sql ) { + return null; + } + return '' === $where_sql ? $predicates : array_merge( $predicates, array( $where_sql ) ); + } + private function translate_mysql_update_set_clause( string $table_name, ?string $alias, array $tokens, int $start, int $end, ?array $scope = null, ?array $information_schema_context = null, bool $derived_source = false, bool $require_supported_expression = false ): ?array { + $column_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $assignments = array(); + $select_expressions = array(); + $changed_predicates = array(); + $source_alias_sql = $this->connection->quote_identifier( 'mysql_update_values' ); + $value_index = 0; + $scope = $scope ?? $this->get_mysql_single_table_scope( $table_name, $alias ); + + $assignment_ranges = $this->get_mysql_update_assignment_ranges( + $tokens, + $start, + $end, + function ( int $position ) use ( $table_name, $alias, $tokens, $end ) { + return $this->parse_simple_mysql_update_assignment_target( $table_name, $alias, $tokens, $position, $end ); + } + ); + if ( null === $assignment_ranges ) { + return null; + } + + foreach ( $assignment_ranges as $assignment_range ) { + $target = $assignment_range['target']; + $target_column = $target['column']; + $value_start = $assignment_range['value_start']; + $value_end = $assignment_range['value_end']; + if ( $require_supported_expression && ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $value_start, $value_end ) ) { + return null; + } + + $target_column_key = strtolower( $target_column ); + $target_metadata = $column_metadata[ $target_column_key ] ?? null; + $value_sql = $this->get_mysql_joined_update_assignment_value_sql( + $target_metadata, + $tokens, + $value_start, + $value_end, + $scope, + $information_schema_context + ); + if ( null === $value_sql ) { + return null; + } + + if ( $derived_source ) { + $value_alias = 'mysql_update_value_' . $value_index; + $value_alias_sql = $this->connection->quote_identifier( $value_alias ); + + $select_expressions[] = sprintf( '%s AS %s', $value_sql, $value_alias_sql ); + $value_sql = $source_alias_sql . '.' . $value_alias_sql; + ++$value_index; + } + + $assignments[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + $value_sql + ); + $changed_predicates[] = sprintf( + '%s IS DISTINCT FROM (%s)', + $this->get_postgresql_dml_column_reference_sql( $target_column, $alias ), + $value_sql + ); + } + + $result = array( + 'set_sql' => implode( ', ', $assignments ), + 'changed_predicate_sql' => implode( ' OR ', $changed_predicates ), + ); + if ( $derived_source ) { + $result['select_sql'] = $select_expressions; + } + return $result; + } + + private function get_mysql_update_assignment_ranges( array $tokens, int $start, int $end, callable $target_parser ): ?array { + $assignments = array(); + + for ( $position = $start; $position < $end; ) { + $target = $target_parser( $position ); + if ( + null === $target + || ! isset( $tokens[ $target['end'] ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id + ) { + return null; + } + + $value_start = $target['end'] + 1; + $value_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $value_end ) { + return null; + } + + $assignments[] = array( + 'target' => $target, + 'value_start' => $value_start, + 'value_end' => $value_end, + ); + + $position = $value_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + if ( empty( $assignments ) ) { + return null; + } + return $assignments; + } + + /** Translate a joined UPDATE assignment expression for a derived source. */ + private function get_mysql_joined_update_assignment_value_sql( ?array $target_metadata, array $tokens, int $start, int $end, array $scope, ?array $information_schema_context = null ): ?string { + return $this->get_mysql_dml_target_value_sql( $target_metadata, $tokens, $start, $end, $scope, null, $information_schema_context, null, true ); + } + + private function get_mysql_dml_target_value_sql( ?array $target_metadata, array $tokens, int $start, int $end, array $scope, ?array $expression_replacements = null, ?array $information_schema_context = null, ?string $scalar_subquery_sql = null, bool $use_non_strict_null_default = false, ?string $expression_sql = null, bool $expression_changed = false ): ?string { + if ( null !== $target_metadata ) { + $this->validate_strict_mysql_dml_value_for_column( $target_metadata, $tokens, $start, $end ); + } + + $column_type = (string) ( $target_metadata['column_type'] ?? '' ); + if ( null !== $scalar_subquery_sql ) { + $value_sql = $scalar_subquery_sql; + if ( $this->is_mysql_text_family_column_type( $column_type ) ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } elseif ( ! $this->is_mysql_strict_sql_mode_active() && $this->is_mysql_integer_family_column_type( $column_type ) ) { + $value_sql = $this->get_postgresql_mysql_integer_cast_sql( $value_sql ); + } + } elseif ( null !== $target_metadata && $use_non_strict_null_default && ! $this->is_mysql_strict_sql_mode_active() && $this->is_mysql_null_token_sequence( $tokens, $start, $end ) ) { + $value_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); + } else { + $value_sql = null; + } + + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $start, $end ) ?? $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $start, $end ); + } + if ( null === $value_sql && null !== $information_schema_context ) { + $value_sql = $this->translate_direct_information_schema_dml_predicate_to_postgresql( null, $tokens, $start, $end, $information_schema_context ); + if ( null === $value_sql ) { + return null; + } + } + if ( null === $value_sql ) { + if ( null !== $expression_sql ) { + $value_sql = $expression_sql; + $changed = $expression_changed; + } elseif ( empty( $expression_replacements ) ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( $tokens, $start, $end, $scope ); + $value_sql = $expression_sql['sql']; + $changed = $expression_sql['changed']; + } else { + $value_sql = $this->translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( $tokens, $start, $end, $expression_replacements ); + if ( null === $value_sql ) { + return null; + } + $changed = true; + } + if ( + $changed + && null !== $target_metadata + && $this->is_mysql_text_family_column_type( $column_type ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } + } + if ( null !== $target_metadata ) { + $value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( $target_metadata, $tokens, $start, $end, $value_sql ) ?? $value_sql; + } + return $value_sql; + } + + /** Get optional WHERE, ORDER BY, and LIMIT positions for joined DML. */ + private function get_mysql_joined_dml_clause_positions( array $tokens, int $start, int $statement_end ): ?array { + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $start, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $start, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $start, $statement_end ); + $order_end = $limit_position ?? $statement_end; + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $order_end ) ) + ) { + return null; + } + return array( + 'where' => $where_position, + 'order' => $order_position, + 'limit' => $limit_position, + 'body_end' => $where_position ?? $order_position ?? $limit_position ?? $statement_end, + ); + } + + private function get_mysql_joined_update_source_clause_sql( string $query, array $tokens, array $clauses, int $statement_end, array $scope, ?array $information_schema_context, bool $require_supported_expression, array $predicates = array(), bool $wrap_single_predicate = true ): ?array { + $predicates = $this->get_mysql_joined_dml_predicates_from_clauses( $query, $tokens, $clauses, $statement_end, $scope, $information_schema_context, $require_supported_expression, $predicates ); + if ( null === $predicates ) { + return null; + } + $tail = $this->translate_simple_mysql_dml_order_limit_sql( $tokens, $clauses['order'], $clauses['limit'], $statement_end, $scope ); + if ( null === $tail ) { + return null; + } + $where_sql = empty( $predicates ) ? '' : ( ( $wrap_single_predicate || count( $predicates ) > 1 ) ? ' WHERE (' . implode( ') AND (', $predicates ) . ')' : ' WHERE ' . $predicates[0] ); + return array_combine( array( 'where', 'order', 'limit' ), array( $where_sql, $tail['order'], $tail['limit'] ) ); + } + + private function get_mysql_joined_update_derived_source_update_sql_from_statement( string $query, array $tokens, int $statement_end, array $statement_parts, array $target_reference, array $scope, ?array $information_schema_context = null, array $predicates = array(), bool $wrap_single_predicate = true ): ?string { + $source_clause_sql = $this->get_mysql_joined_update_source_clause_sql( $query, $tokens, $statement_parts['clauses'], $statement_end, $scope, $information_schema_context, true, $predicates, $wrap_single_predicate ); + if ( null === $source_clause_sql ) { + return null; + } + + if ( ! $this->mysql_scope_references_non_public_schema( $scope ) ) { + $source_range_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $statement_parts['source_start'], $statement_parts['set_position'] ); + } else { + $source_range_sql = $this->translate_mysql_table_reference_range_to_postgresql( $tokens, $statement_parts['source_start'], $statement_parts['set_position'] ); + } + if ( null === $source_range_sql ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['table_as'] ?? $target_reference['alias']; + $target_reference_alias = $target_reference['alias'] ?? ( null === $alias ? $table_name : $alias ); + return $this->get_mysql_joined_update_derived_source_update_sql( $table_name, $alias, $target_reference_alias, $tokens, $statement_parts['set_position'] + 1, $statement_parts['set_end'], $scope, $information_schema_context, $source_range_sql, $source_clause_sql ); + } + + /** Render a materialized joined UPDATE source. */ + private function get_mysql_joined_update_derived_source_sql( string $source_range_sql, string $where_sql, string $order_sql, string $limit_sql, string $target_alias, string $target_ctid_alias, array $assignment_select_sql ): string { + $select_values = array_merge( + array( + sprintf( + '%s.ctid AS %s', + $this->connection->quote_identifier( $target_alias ), + $this->connection->quote_identifier( $target_ctid_alias ) + ), + ), + $assignment_select_sql + ); + return sprintf( + '(SELECT %s FROM %s%s%s%s) AS %s', + implode( ', ', $select_values ), + $source_range_sql, + $where_sql, + $order_sql, + $limit_sql, + $this->connection->quote_identifier( 'mysql_update_values' ) + ); + } + + private function get_mysql_joined_update_derived_source_update_sql( string $table_name, ?string $alias, string $target_reference_alias, array $tokens, int $set_start, int $set_end, array $scope, ?array $information_schema_context, string $source_range_sql, array $source_clause_sql ): ?string { + $update_set_clause = $this->translate_mysql_update_set_clause( $table_name, $target_reference_alias, $tokens, $set_start, $set_end, $scope, $information_schema_context, true ); + if ( null === $update_set_clause ) { + return null; + } + + $target_ctid_alias = 'mysql_update_target_ctid'; + $source_sql = $this->get_mysql_joined_update_derived_source_sql( + $source_range_sql, + $source_clause_sql['where'], + $source_clause_sql['order'], + $source_clause_sql['limit'], + $target_reference_alias, + $target_ctid_alias, + $update_set_clause['select_sql'] + ); + return $this->get_mysql_joined_update_by_derived_source_sql( + $table_name, + $alias, + $source_sql, + $target_ctid_alias, + $update_set_clause + ); + } + + /** Render an UPDATE that reads ctid and assignment values from a derived source. */ + private function get_mysql_joined_update_by_derived_source_sql( string $table_name, ?string $alias, string $source_sql, string $target_ctid_alias, array $update_set_clause ): string { + $source_alias_sql = $this->connection->quote_identifier( 'mysql_update_values' ); + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s = %s.%s) AND (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + $source_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $source_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ), + $update_set_clause['changed_predicate_sql'] + ); + } + private function translate_mysql_inner_join_update_query( string $query, array $tokens, int $statement_end ): ?string { + $statement_parts = $this->get_mysql_joined_update_statement_parts( $tokens, $statement_end ); + if ( null === $statement_parts ) { + return null; + } + $set_position = $statement_parts['set_position']; + $source_start = $statement_parts['source_start']; + $first_reference = $statement_parts['first_reference']; + + if ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + || $this->direct_information_schema_source_range_references_information_schema( $tokens, $source_start, $set_position ) + ) { + return $this->translate_mysql_information_schema_join_update_query( + $query, + $tokens, + $statement_end, + $source_start, + $set_position, + $first_reference + ); + } + + $source_plan = $this->get_mysql_joined_update_source_plan( + $tokens, + $statement_parts['source_position'], + $set_position, + $first_reference + ); + if ( null === $source_plan ) { + return null; + } + $scope = $source_plan['scope']; + $join_predicates = $source_plan['join_predicates']; + $table_references = $source_plan['table_references']; + + $clauses = $statement_parts['clauses']; + $set_end = $statement_parts['set_end']; + + $target_reference = $this->get_mysql_joined_update_target_reference( + $tokens, + $set_position + 1, + $set_end, + $table_references + ); + if ( null === $target_reference ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['table_as']; + $target_reference_alias = $target_reference['alias']; + $from_parts = array_diff_key( $source_plan['from_parts'], array( $target_reference['alias_key'] => true ) ); + if ( empty( $from_parts ) ) { + return null; + } + + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + return $this->get_mysql_joined_update_derived_source_update_sql_from_statement( '', $tokens, $statement_end, $statement_parts, $target_reference, $scope, null, $join_predicates ); + } + + $predicates = $this->get_mysql_joined_dml_predicates_from_clauses( '', $tokens, $clauses, $statement_end, $scope, null, true, $join_predicates ); + if ( null === $predicates ) { + return null; + } + + $update_set_clause = $this->translate_mysql_update_set_clause( + $table_name, + $target_reference_alias, + $tokens, + $set_position + 1, + $set_end, + $scope + ); + if ( null === $update_set_clause ) { + return null; + } + + $predicates[] = $update_set_clause['changed_predicate_sql']; + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + implode( ', ', $from_parts ), + implode( ') AND (', $predicates ) + ); + } + private function translate_mysql_information_schema_join_update_query( string $query, array $tokens, int $statement_end, int $source_start, int $source_end, array $target_reference ): ?string { + $source_translation = $this->get_direct_information_schema_dml_source_translation( + $query, + $tokens, + $source_start, + $source_end + ); + if ( null === $source_translation ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['alias']; + $target_reference_alias = null === $alias ? $table_name : $alias; + $target_alias_key = strtolower( $target_reference_alias ); + if ( + ! isset( $source_translation['scope']['aliases'][ $target_alias_key ] ) + || 0 !== strcasecmp( $table_name, (string) $source_translation['scope']['aliases'][ $target_alias_key ]['table'] ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $source_end + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $source_end + 1, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $source_end + 1, $statement_end ); + if ( + null !== $limit_position + || ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $statement_end ) ) + ) { + return null; + } + + $set_end = $where_position ?? $order_position ?? $statement_end; + if ( $source_end + 1 >= $set_end ) { + return null; + } + + $where_sql = $this->get_mysql_joined_dml_where_sql_from_clauses( + $query, + $tokens, + array( + 'where' => $where_position, + 'order' => $order_position, + 'limit' => null, + ), + $statement_end, + $source_translation['scope'], + $source_translation['context'], + false + ); + if ( null === $where_sql ) { + return null; + } + $where_sql = '' === $where_sql ? '' : ' WHERE ' . $where_sql; + + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_direct_information_schema_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $statement_end, + $source_translation['context'] + ); + if ( null === $order_sql ) { + return null; + } + } + + return $this->get_mysql_joined_update_derived_source_update_sql( + $table_name, + $alias, + $target_reference_alias, + $tokens, + $source_end + 1, + $set_end, + $source_translation['scope'], + $source_translation['context'], + $source_translation['sql'], + array_combine( array( 'where', 'order', 'limit' ), array( $where_sql, $order_sql, '' ) ) + ); + } + private function translate_direct_information_schema_dml_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $context ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $item_ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $item_ranges || empty( $item_ranges ) ) { + return null; + } + + $items = array(); + foreach ( $item_ranges as $item_range ) { + $item_start = $item_range['start']; + $item_end = $item_range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( $item_start >= $item_end ) { + return null; + } + + $item_sql = $this->translate_direct_information_schema_dml_order_by_item_to_postgresql( + $tokens, + $item_start, + $item_end, + $context + ); + if ( null === $item_sql ) { + return null; + } + + $items[] = $item_sql . $direction; + } + return ' ORDER BY ' . implode( ', ', $items ); + } + private function translate_direct_information_schema_dml_order_by_item_to_postgresql( array $tokens, int $start, int $end, array $context ): ?string { + if ( + $start + 1 === $end + && isset( $tokens[ $start ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $start ] ) + ) { + return $tokens[ $start ]->get_bytes(); + } + + $reference = $this->get_direct_information_schema_column_reference_for_expression( $tokens, $start, $end, $context ); + return null === $reference ? null : $reference['sql']; + } + private function translate_mysql_multi_target_update_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $statement_parts = $this->get_mysql_joined_update_statement_parts( $tokens, $statement_end ); + if ( null === $statement_parts ) { + return null; + } + + $source_plan = $this->get_mysql_joined_update_source_plan( + $tokens, + $statement_parts['source_position'], + $statement_parts['set_position'], + $statement_parts['first_reference'] + ); + if ( null === $source_plan ) { + return null; + } + $scope = $source_plan['scope']; + $join_predicates = $source_plan['join_predicates']; + $table_references = $source_plan['table_references']; + + $clauses = $statement_parts['clauses']; + $set_end = $statement_parts['set_end']; + + $update_set_clause = $this->translate_mysql_multi_target_update_set_clause_for_derived_source( + $tokens, + $statement_parts['set_position'] + 1, + $set_end, + $table_references, + $scope + ); + if ( null === $update_set_clause || count( $update_set_clause['targets'] ) < 2 ) { + return null; + } + + $source_clause_sql = $this->get_mysql_joined_update_source_clause_sql( '', $tokens, $clauses, $statement_end, $scope, null, true, $join_predicates ); + if ( null === $source_clause_sql ) { + return null; + } + + $source_sql = sprintf( + 'mysql_update_rows AS MATERIALIZED (SELECT %s FROM %s%s%s%s)', + implode( ', ', $update_set_clause['select_sql'] ), + implode( ', ', array_values( $source_plan['from_parts'] ) ), + $source_clause_sql['where'], + $source_clause_sql['order'], + $source_clause_sql['limit'] + ); + + $update_ctes = array(); + $count_parts = array(); + $index = 0; + foreach ( $update_set_clause['targets'] as $target ) { + $cte_name = 'mysql_update_target_' . $index; + $target_table_sql = $this->get_postgresql_dml_table_reference_sql( $target['table'], $target['table_as'] ); + $target_ctid_sql = $this->get_postgresql_dml_ctid_reference_sql( $target['table_as'] ); + $source_ctid_alias_sql = 'mysql_update_rows.' . $this->connection->quote_identifier( $target['ctid_alias'] ); + $update_ctes[] = sprintf( + '%s AS (UPDATE %s SET %s FROM mysql_update_rows WHERE (%s = %s) AND (%s) RETURNING 1)', + $cte_name, + $target_table_sql, + implode( ', ', $target['assignments'] ), + $target_ctid_sql, + $source_ctid_alias_sql, + implode( ' OR ', $target['changed_predicates'] ) + ); + $count_parts[] = sprintf( '(SELECT COUNT(*) FROM %s)', $cte_name ); + ++$index; + } + return sprintf( + 'WITH %s, %s SELECT %s AS affected_rows', + $source_sql, + implode( ', ', $update_ctes ), + implode( ' + ', $count_parts ) + ); + } + private function translate_mysql_multi_target_update_set_clause_for_derived_source( array $tokens, int $start, int $end, array $table_references, array $scope ): ?array { + $targets = array(); + $target_physical_keys = array(); + $select_expressions = array(); + $value_index = 0; + + $assignment_ranges = $this->get_mysql_update_assignment_ranges( + $tokens, + $start, + $end, + function ( int $position ) use ( $tokens, $end, $table_references ) { + return $this->parse_mysql_joined_update_assignment_target( $tokens, $position, $end, $table_references ); + } + ); + if ( null === $assignment_ranges ) { + return null; + } + + foreach ( $assignment_ranges as $assignment_range ) { + $target = $assignment_range['target']; + $target_reference = $target['reference']; + + if ( + ! empty( $target_reference['derived'] ) + || empty( $target_reference['table'] ) + ) { + return null; + } + + $table_name = (string) $target_reference['table']; + $target_column = $target['column']; + $column_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $target_metadata = $column_metadata[ strtolower( $target_column ) ] ?? null; + $value_sql = $this->get_mysql_joined_update_assignment_value_sql( + $target_metadata, + $tokens, + $assignment_range['value_start'], + $assignment_range['value_end'], + $scope + ); + if ( null === $value_sql ) { + return null; + } + + $target_alias_key = $target_reference['alias_key']; + if ( ! isset( $targets[ $target_alias_key ] ) ) { + $physical_key = strtolower( 'public.' . $table_name ); + if ( isset( $target_physical_keys[ $physical_key ] ) ) { + return null; + } + $target_physical_keys[ $physical_key ] = true; + + $ctid_alias = 'mysql_update_' . count( $targets ) . '_ctid'; + $targets[ $target_alias_key ] = array( + 'table' => $table_name, + 'table_as' => $target_reference['table_as'], + 'ctid_alias' => $ctid_alias, + 'assignments' => array(), + 'changed_predicates' => array(), + ); + $select_expressions[] = sprintf( + '%s.ctid AS %s', + $this->connection->quote_identifier( $target_reference['alias'] ), + $this->connection->quote_identifier( $ctid_alias ) + ); + } + + $value_alias = 'mysql_update_value_' . $value_index; + $value_alias_sql = $this->connection->quote_identifier( $value_alias ); + $source_value_sql = 'mysql_update_rows.' . $value_alias_sql; + + $select_expressions[] = sprintf( '%s AS %s', $value_sql, $value_alias_sql ); + $targets[ $target_alias_key ]['assignments'][] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + $source_value_sql + ); + $targets[ $target_alias_key ]['changed_predicates'][] = sprintf( + '%s IS DISTINCT FROM (%s)', + $this->get_postgresql_dml_column_reference_sql( $target_column, $target_reference['table_as'] ), + $source_value_sql + ); + + ++$value_index; + } + + return array( + 'select_sql' => $select_expressions, + 'targets' => $targets, + ); + } + + /** Build the supported joined UPDATE source plan. */ + private function get_mysql_joined_update_source_plan( array $tokens, int $position, int $end, array $first_reference ): ?array { + $first_table = $first_reference['table']; + $first_alias = $first_reference['alias']; + $first_reference_alias = null === $first_alias ? $first_table : $first_alias; + $scope = $this->get_mysql_single_table_scope( $first_table, $first_alias ); + $join_predicates = array(); + $current_join_left_alias = $first_reference_alias; + $table_references = array( + $this->get_mysql_joined_update_table_reference_descriptor( $first_reference_alias, $first_table, $first_alias, false, $this->get_postgresql_dml_table_reference_sql( $first_table, $first_alias ) ), + ); + + while ( $position < $end ) { + if ( WP_MySQL_Lexer::COMMA_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $source_reference = $this->append_mysql_joined_update_source_table( $tokens, $position, $end, $scope ); + if ( null === $source_reference ) { + return null; + } + $table_references[] = $source_reference; + $current_join_left_alias = $source_reference['alias']; + continue; + } + + if ( + ! $this->is_mysql_supported_inner_join_separator_at( $tokens, $position, $end ) + || ! $this->append_mysql_joined_update_inner_join( + $tokens, + $position, + $end, + $scope, + $join_predicates, + $current_join_left_alias, + $table_references + ) + ) { + return null; + } + } + + if ( count( $table_references ) < 2 ) { + return null; + } + return array( + 'from_parts' => array_column( $table_references, 'sql', 'alias_key' ), + 'scope' => $scope, + 'join_predicates' => $join_predicates, + 'table_references' => $table_references, + ); + } + private function consume_mysql_update_modifiers( array $tokens, int &$position, int $end ): void { + $this->consume_mysql_dml_modifiers( $tokens, $position, $end, array( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::IGNORE_SYMBOL ) ); + } + private function consume_mysql_delete_modifiers( array $tokens, int &$position ): void { + $this->consume_mysql_dml_modifiers( $tokens, $position, count( $tokens ), array( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::QUICK_SYMBOL, WP_MySQL_Lexer::IGNORE_SYMBOL ) ); + } + private function consume_mysql_insert_priority_modifier( array $tokens, int &$position ): void { + $this->consume_mysql_dml_modifiers( $tokens, $position, $position + 1, array( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::DELAYED_SYMBOL, WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL ) ); + } + private function consume_mysql_replace_priority_modifier( array $tokens, int &$position ): void { + $this->consume_mysql_dml_modifiers( $tokens, $position, $position + 1, array( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, WP_MySQL_Lexer::DELAYED_SYMBOL ) ); + } + private function consume_mysql_dml_modifiers( array $tokens, int &$position, int $end, array $token_ids, ?int $match_token_id = null ): bool { + $matched = false; + while ( $position < $end && in_array( $tokens[ $position ]->id ?? null, $token_ids, true ) ) { + $matched = $matched || $match_token_id === $tokens[ $position ]->id; + ++$position; + } + return $matched; + } + private function get_mysql_joined_update_target_reference( array $tokens, int $start, int $end, array $table_references ): ?array { + $target_reference = null; + + $assignment_ranges = $this->get_mysql_update_assignment_ranges( + $tokens, + $start, + $end, + function ( int $position ) use ( $tokens, $end, $table_references ) { + return $this->parse_mysql_joined_update_assignment_target( $tokens, $position, $end, $table_references ); + } + ); + if ( null === $assignment_ranges ) { + return null; + } + + foreach ( $assignment_ranges as $assignment_range ) { + $target = $assignment_range['target']; + if ( null === $target_reference ) { + $target_reference = $target['reference']; + } elseif ( $target_reference['alias_key'] !== $target['alias_key'] ) { + return null; + } + } + return null !== $target_reference && empty( $target_reference['derived'] ) ? $target_reference : null; + } + private function parse_mysql_joined_update_assignment_target( array $tokens, int $position, int $end, array $table_references ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column ) { + return null; + } + + $target_reference = $this->get_mysql_joined_update_reference_for_qualifier( $first_identifier, $table_references ); + if ( null === $target_reference ) { + return null; + } + return array( + 'alias_key' => $target_reference['alias_key'], + 'column' => $column, + 'end' => $position + 3, + 'reference' => $target_reference, + ); + } + + $target_reference = $this->get_mysql_joined_update_reference_for_unqualified_column( + $first_identifier, + $table_references + ); + if ( null === $target_reference ) { + return null; + } + return array( + 'alias_key' => $target_reference['alias_key'], + 'column' => $first_identifier, + 'end' => $position + 1, + 'reference' => $target_reference, + ); + } + private function get_mysql_joined_update_reference_for_qualifier( string $qualifier, array $table_references ): ?array { + $qualifier_key = strtolower( $qualifier ); + foreach ( $table_references as $table_reference ) { + if ( $qualifier_key === $table_reference['alias_key'] ) { + return $table_reference; + } + } + + $matched_reference = null; + foreach ( $table_references as $table_reference ) { + if ( + ! empty( $table_reference['derived'] ) + || null === $table_reference['table'] + || 0 !== strcasecmp( $qualifier, $table_reference['table'] ) + ) { + continue; + } + + if ( null !== $matched_reference ) { + return null; + } + + $matched_reference = $table_reference; + } + return $matched_reference; + } + private function get_mysql_joined_update_reference_for_unqualified_column( string $column_name, array $table_references ): ?array { + $real_table_references = array(); + foreach ( $table_references as $table_reference ) { + if ( + empty( $table_reference['derived'] ) + && ! empty( $table_reference['table'] ) + ) { + $real_table_references[] = $table_reference; + } + } + + if ( 1 === count( $real_table_references ) ) { + return $real_table_references[0]; + } + + $matched_reference = null; + foreach ( $real_table_references as $table_reference ) { + $table_name = (string) $table_reference['table']; + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + if ( ! $this->mysql_table_has_column_for_translation( $table_schema, $table_name, $column_name ) ) { + continue; + } + + if ( null !== $matched_reference ) { + return null; + } + + $matched_reference = $table_reference; + } + return $matched_reference; + } + private function mysql_table_has_column_for_translation( string $table_schema, string $table_name, string $column_name ): bool { + if ( $this->mysql_table_has_column_metadata( $table_schema, $table_name ) ) { + return null !== $this->get_cached_mysql_table_column_type( $table_schema, $table_name, $column_name ); + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?) + LIMIT 1', + array( $table_schema, $table_name, $column_name ) + ); + return false !== $stmt->fetchColumn(); + } + private function append_mysql_joined_update_source_table( array $tokens, int &$position, int $end, array &$scope ): ?array { + $derived_reference = $this->parse_mysql_joined_update_derived_table_source( $tokens, $position, $end ); + if ( null !== $derived_reference ) { + $joined_alias_key = strtolower( $derived_reference['alias'] ); + if ( isset( $scope['aliases'][ $joined_alias_key ] ) ) { + return null; + } + + $scope['aliases'][ $joined_alias_key ] = array( + 'schema' => null, + 'table' => null, + 'derived' => true, + ); + $scope['unknown'] = true; + return $this->get_mysql_joined_update_table_reference_descriptor( $derived_reference['alias'], null, null, true, $derived_reference['sql'] ); + } + + $joined_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $end ); + if ( null === $joined_reference ) { + return null; + } + + $joined_alias = null === $joined_reference['alias'] ? $joined_reference['table'] : $joined_reference['alias']; + $joined_alias_key = strtolower( $joined_alias ); + if ( isset( $scope['aliases'][ $joined_alias_key ] ) ) { + return null; + } + + $joined_table = array( + 'schema' => $this->get_mysql_unqualified_dml_table_backend_schema( $joined_reference['table'] ), + 'table' => $joined_reference['table'], + ); + + $scope['tables'][] = $joined_table; + $scope['aliases'][ $joined_alias_key ] = $joined_table; + + $source_sql = $this->get_postgresql_dml_table_reference_sql( + $joined_reference['table'], + $joined_reference['alias'] + ); + return $this->get_mysql_joined_update_table_reference_descriptor( $joined_alias, $joined_reference['table'], $joined_reference['alias'], false, $source_sql ); + } + private function get_mysql_joined_update_table_reference_descriptor( string $alias, ?string $table, ?string $table_as, bool $derived, string $sql ): array { + return array( + 'alias' => $alias, + 'alias_key' => strtolower( $alias ), + 'table' => $table, + 'table_as' => $table_as, + 'derived' => $derived, + 'sql' => $sql, + ); + } + private function parse_mysql_joined_update_derived_table_source( array $tokens, int &$position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $locking_start = $this->find_mysql_select_row_locking_clause_start( $tokens, $select_start + 1, $select_end ); + if ( null !== $locking_start ) { + $select_end = $locking_start; + } + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + $alias_position = $after_close; + if ( $alias_position < $end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $alias_position ]->id ?? null ) ) { + ++$alias_position; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $alias_position ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position = $alias_position + 1; + return array( + 'alias' => $alias, + 'sql' => sprintf( + '(%s) AS %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ), + $this->connection->quote_identifier( $alias ) + ), + ); + } + private function append_mysql_joined_update_inner_join( array $tokens, int &$position, int $end, array &$scope, array &$join_predicates, string &$left_alias, array &$table_references ): bool { + $predicate_optional = false; + if ( WP_MySQL_Lexer::INNER_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + } elseif ( WP_MySQL_Lexer::CROSS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + $predicate_optional = true; + } elseif ( WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $predicate_optional = true; + } else { + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + } + + $joined_reference = $this->append_mysql_joined_update_source_table( $tokens, $position, $end, $scope ); + if ( null === $joined_reference ) { + return false; + } + $table_references[] = $joined_reference; + $joined_alias = $joined_reference['alias']; + + if ( WP_MySQL_Lexer::ON_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $predicate_start = $position + 1; + $predicate_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::CROSS_SYMBOL, + WP_MySQL_Lexer::INNER_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LEFT_SYMBOL, + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::RIGHT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + $predicate_start, + $end + ) ?? $end; + if ( + $predicate_start >= $predicate_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $predicate_start, $predicate_end ) + ) { + return false; + } + + $predicate_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $predicate_start, + $predicate_end, + $scope + ); + $join_predicates[] = $predicate_sql['sql']; + $position = $predicate_end; + $left_alias = $joined_alias; + return true; + } + + if ( WP_MySQL_Lexer::USING_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + if ( $predicate_optional ) { + $left_alias = $joined_alias; + return true; + } + return false; + } + + $using_position = $position + 1; + $using_columns = $this->parse_mysql_identifier_list( $tokens, $using_position ); + if ( null === $using_columns ) { + return false; + } + + foreach ( $using_columns as $using_column ) { + $left_alias_sql = $this->translate_mysql_identifier_value_to_postgresql( $left_alias ); + $joined_alias_sql = $this->translate_mysql_identifier_value_to_postgresql( $joined_alias ); + $using_column_sql = $this->translate_mysql_identifier_value_to_postgresql( $using_column ); + $join_predicates[] = sprintf( + '%s.%s = %s.%s', + $left_alias_sql, + $using_column_sql, + $joined_alias_sql, + $using_column_sql + ); + } + + $position = $using_position; + $left_alias = $joined_alias; + return true; + } + private function is_mysql_supported_inner_join_separator_at( array $tokens, int $position, int $end ): bool { + if ( $position >= $end ) { + return false; + } + + if ( + WP_MySQL_Lexer::JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + return true; + } + return $position + 1 < $end + && ( + WP_MySQL_Lexer::INNER_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::CROSS_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) + && WP_MySQL_Lexer::JOIN_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ); + } + private function get_non_strict_dml_defaults_for_omitted_columns( string $table_name, array $columns, ?array $column_metadata = null ): array { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return array(); + } + + $supplied_columns = array(); + foreach ( $columns as $column ) { + $supplied_columns[ strtolower( (string) $column ) ] = true; + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + + $defaults = array(); + foreach ( $column_metadata as $column_metadata_row ) { + $column_name = (string) ( $column_metadata_row['column_name'] ?? '' ); + if ( '' === $column_name || isset( $supplied_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata_row ); + if ( null === $default_sql ) { + continue; + } + + $defaults[] = array( + 'column' => $column_name, + 'sql' => $default_sql, + ); + + $supplied_columns[ strtolower( $column_name ) ] = true; + } + return $defaults; + } + private function append_non_strict_dml_defaults_for_omitted_value_rows( string $table_name, array &$columns, array &$value_rows, ?array $column_metadata = null ): void { + $default_columns = $this->get_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $column_metadata ); + if ( empty( $default_columns ) ) { + return; + } + + $default_sql_values = array(); + foreach ( $default_columns as $default_column ) { + $columns[] = $default_column['column']; + $default_sql_values[] = $default_column['sql']; + } + + foreach ( $value_rows as &$values ) { + foreach ( $default_sql_values as $default_sql ) { + $values[] = $default_sql; + } + } + unset( $values ); + } + private function parse_simple_mysql_update_assignment_target( string $table_name, ?string $alias, array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column || ! $this->is_mysql_dml_table_qualifier( $first_identifier, $table_name, $alias ) ) { + return null; + } + return array( + 'column' => $column, + 'end' => $position + 3, + ); + } + return array( + 'column' => $first_identifier, + 'end' => $position + 1, + ); + } + private function normalize_mysql_dml_value_rows_for_columns( string $table_name, array $columns, array &$value_rows, array $value_range_rows, array $tokens, ?array $column_metadata ): array { + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + + $is_strict = $this->is_mysql_strict_sql_mode_active(); + foreach ( $value_rows as $row_index => &$values ) { + $value_ranges = $value_range_rows[ $row_index ] ?? array(); + $value_columns = $this->get_mysql_dml_value_column_descriptors( $columns, $value_ranges, $column_metadata ); + if ( $is_strict ) { + foreach ( $value_columns as $value_column ) { + $this->validate_strict_mysql_dml_value_for_column( $value_column['metadata'], $tokens, $value_column['start'], $value_column['end'] ); + } + } + $this->normalize_mysql_auto_increment_zero_values_for_columns( $value_columns, $values, $tokens ); + $this->normalize_mysql_dml_values_for_columns( $value_columns, $values, $tokens, $is_strict ); + } + unset( $values ); + return $column_metadata; + } + private function get_mysql_dml_value_column_descriptors( array $columns, array $value_ranges, array $metadata ): array { + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + $value_columns = array(); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) ) { + continue; + } + + $value_columns[] = array( + 'index' => $index, + 'metadata' => $column_metadata[ $column_key ], + 'start' => (int) $value_ranges[ $index ]['start'], + 'end' => (int) $value_ranges[ $index ]['end'], + ); + } + return $value_columns; + } + private function normalize_mysql_auto_increment_zero_values_for_columns( array $value_columns, array &$values, array $tokens ): void { + if ( $this->is_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) ) { + return; + } + + foreach ( $value_columns as $value_column ) { + if ( + ! isset( $values[ $value_column['index'] ] ) + || ! $this->is_mysql_auto_increment_column_metadata( $value_column['metadata'] ) + ) { + continue; + } + + if ( ! $this->is_mysql_zero_literal_range( $tokens, $value_column['start'], $value_column['end'] ) ) { + continue; + } + + $values[ $value_column['index'] ] = 'DEFAULT'; + } + } + private function get_mysql_insert_select_auto_increment_generated_value_sql( string $table_name, array $column_metadata ): string { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + return sprintf( + 'nextval(pg_get_serial_sequence(%s, %s))', + $this->connection->quote( $table_schema . '.' . $table_name ), + $this->connection->quote( $column_name ) + ); + } + private function is_mysql_generated_auto_increment_value_sql( string $value_sql ): bool { + return in_array( strtoupper( trim( $value_sql ) ), array( 'DEFAULT', 'NULL' ), true ); + } + private function validate_strict_mysql_dml_value_for_column( array $column_metadata, array $tokens, int $start, int $end ): void { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $this->get_strict_mysql_dml_value_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + private function get_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return null; + } + + $this->validate_strict_mysql_dml_text_length_for_column( $column_metadata, $tokens, $start, $end ); + $text_hex_sql = $this->get_mysql_text_hex_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $text_hex_sql ) { + return $text_hex_sql; + } + + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + + if ( in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return $this->get_mysql_dml_date_time_literal_sql_for_column( $base_type, $tokens, $start, $end, true ); + } + + if ( 'year' === $base_type ) { + return $this->get_mysql_dml_year_literal_sql_for_column( $tokens, $start, $end ); + } + return $this->get_strict_mysql_dml_integer_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + private function get_mysql_dml_date_time_literal_sql_for_column( string $base_type, array $tokens, int $start, int $end, bool $strict ): ?string { + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = $literal['value']; + if ( 'string' !== $literal['type'] ) { + if ( $strict ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + if ( 'numeric' === $literal['type'] ) { + $value = trim( $value ); + if ( '' !== $value && isset( $value[0] ) && '+' === $value[0] ) { + $value = substr( $value, 1 ); + } + } + if ( + 'boolean' !== $literal['type'] + && ( 'numeric' !== $literal['type'] || '' === $value || 1 !== preg_match( '/^-?(?:0+)(?:\.0+)?(?:[eE][+-]?0+)?$/', $value ) ) + ) { + return null; + } + return $this->connection->quote( 'date' === $base_type ? '0000-00-00' : '0000-00-00 00:00:00' ); + } + + $storage_value = $this->get_mysql_dml_temporal_literal_storage_value( $base_type, $value, $strict ); + if ( null === $storage_value || $storage_value === $value ) { + return null; + } + return $this->connection->quote( $storage_value ); + } + private function get_mysql_dml_temporal_literal_storage_value( string $base_type, string $value, bool $strict ): ?string { + $is_date = 'date' === $base_type; + $normalized_value = $is_date ? $this->normalize_mysql_dml_date_literal_format( $value ) : $this->normalize_mysql_dml_datetime_literal_format( $value ); + $parts = $is_date ? $this->get_mysql_dml_date_parts( $normalized_value ) : $this->get_mysql_dml_datetime_parts( $normalized_value ); + if ( null !== $parts ) { + if ( ! $is_date && ! $this->is_mysql_dml_time_value_valid( $parts['hour'], $parts['minute'], $parts['second'] ) ) { + if ( $strict ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + return '0000-00-00 00:00:00'; + } + if ( $this->are_mysql_dml_temporal_date_parts_storable( $base_type, $value, $parts, $strict ) ) { + return $normalized_value; + } + return $is_date ? '0000-00-00' : '0000-00-00 00:00:00'; + } + + if ( $is_date ) { + if ( $strict ) { + $this->throw_mysql_incorrect_temporal_value( 'date', $value ); + } + return null; + } + + $date_parts = $this->get_mysql_dml_date_parts( $normalized_value ); + if ( null === $date_parts ) { + if ( $strict ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + return null; + } + return $this->are_mysql_dml_temporal_date_parts_storable( $base_type, $value, $date_parts, $strict ) + ? $normalized_value . ' 00:00:00' + : '0000-00-00 00:00:00'; + } + private function are_mysql_dml_temporal_date_parts_storable( string $type, string $value, array $parts, bool $strict ): bool { + if ( $strict ) { + $this->validate_strict_mysql_dml_date_parts( $type, $value, $parts['year'], $parts['month'], $parts['day'] ); + return true; + } + return $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) + || checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ); + } + private function validate_strict_mysql_dml_date_parts( string $type, string $value, string $year, string $month, string $day ): void { + if ( '0000' === $year && '00' === $month && '00' === $day ) { + if ( $this->is_sql_mode_active( 'NO_ZERO_DATE' ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + return; + } + + if ( '0000' !== $year && ( '00' === $month || '00' === $day ) ) { + if ( $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + return; + } + + if ( ! checkdate( (int) $month, (int) $day, (int) $year ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + } + private function throw_mysql_incorrect_temporal_value( string $type, string $value ): void { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $type, $value ) ); + } + private function throw_mysql_out_of_range_value( string $value ): void { + throw new InvalidArgumentException( sprintf( "Out of range value: '%s'", $value ) ); + } + private function validate_strict_mysql_dml_text_length_for_column( array $column_metadata, array $tokens, int $start, int $end ): void { + $column_type = (string) ( $column_metadata['column_type'] ?? '' ); + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + $max_length = null; + if ( in_array( $base_type, array( 'char', 'varchar' ), true ) ) { + $max_length = $this->get_mysql_column_type_display_width( $column_type ); + } elseif ( 'tinytext' === $base_type ) { + $max_length = 255; + } elseif ( 'text' === $base_type ) { + $max_length = 65535; + } elseif ( 'mediumtext' === $base_type ) { + $max_length = 16777215; + } elseif ( 'longtext' === $base_type ) { + $max_length = 4294967295; + } + if ( null === $max_length ) { + return; + } + + $value = $this->get_mysql_text_hex_literal_value( $tokens, $start, $end ); + if ( null === $value ) { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return; + } + + $value = $literal['value']; + } + + $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value ); + if ( $length > $max_length ) { + throw new InvalidArgumentException( sprintf( "Data too long for column '%s'", (string) ( $column_metadata['column_name'] ?? '' ) ) ); + } + } + private function get_mysql_dml_literal_value( array $tokens, int $start, int $end ): ?array { + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return array( + 'type' => 'string', + 'value' => $tokens[ $start ]->get_value(), + ); + } + + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'null', + 'value' => null, + ); + } + + if ( WP_MySQL_Lexer::FALSE_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'boolean', + 'value' => '0', + ); + } + + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'boolean', + 'value' => '1', + ); + } + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null !== $literal && $literal['start'] === $start && $literal['end'] === $end ) { + return array( + 'type' => 'numeric', + 'value' => $this->get_mysql_token_sequence_bytes( $tokens, $start, $end ), + ); + } + return null; + } + private function get_mysql_token_sequence_bytes( array $tokens, int $start, int $end ): string { + $bytes = ''; + for ( $i = $start; $i < $end; $i++ ) { + $bytes .= $tokens[ $i ]->get_bytes(); + } + return $bytes; + } + private function normalize_mysql_dml_values_for_columns( array $value_columns, array &$values, array $tokens, bool $strict ): void { + foreach ( $value_columns as $value_column ) { + if ( $strict ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $value_column['metadata'], $tokens, $value_column['start'], $value_column['end'] ); + if ( null === $value_sql && isset( $values[ $value_column['index'] ] ) ) { + $value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $value_column['metadata'], + $tokens, + $value_column['start'], + $value_column['end'], + (string) $values[ $value_column['index'] ] + ); + } + } else { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $value_column['metadata'], $tokens, $value_column['start'], $value_column['end'] ); + } + + if ( null !== $value_sql ) { + $values[ $value_column['index'] ] = $value_sql; + } + } + } + private function get_strict_mysql_dml_temporal_expression_sql_for_column( array $column_metadata, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return null; + } + + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + if ( + null !== $this->get_mysql_dml_literal_value( $tokens, $start, $end ) + || $this->is_mysql_default_value_token_sequence( $tokens, $start, $end ) + ) { + return null; + } + + $known_valid_value_sql = $this->get_mysql_intrinsically_valid_temporal_expression_sql_for_column( + $base_type, + (string) ( $column_metadata['column_type'] ?? '' ), + $tokens, + $start, + $end, + $value_sql + ); + if ( null !== $known_valid_value_sql ) { + return $known_valid_value_sql === $value_sql ? null : $known_valid_value_sql; + } + + return $this->get_postgresql_mysql_inline_validate_temporal_sql( + $value_sql, + $base_type, + $this->is_sql_mode_active( 'NO_ZERO_DATE' ), + $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) + ); + } + private function get_postgresql_mysql_inline_validate_temporal_sql( string $value_sql, string $mysql_type, bool $reject_zero_date, bool $reject_zero_in_date ): string { + $value_sql_alias = '"__wp_pg_mysql_temporal_value"."value"'; + $date_part_sql = sprintf( 'SUBSTRING(%s FROM 1 FOR 10)', $value_sql_alias ); + $year_text_sql = sprintf( 'SUBSTRING(%s FROM 1 FOR 4)', $date_part_sql ); + $month_text_sql = sprintf( 'SUBSTRING(%s FROM 6 FOR 2)', $date_part_sql ); + $day_text_sql = sprintf( 'SUBSTRING(%s FROM 9 FOR 2)', $date_part_sql ); + $year_sql = sprintf( 'CAST(%s AS integer)', $year_text_sql ); + $month_sql = sprintf( 'CAST(%s AS integer)', $month_text_sql ); + $day_sql = sprintf( 'CAST(%s AS integer)', $day_text_sql ); + $error_sql = sprintf( + "CAST(CAST('__wp_pg_invalid_temporal__' || COALESCE(%s, '') AS timestamp) AS text)", + $value_sql_alias + ); + + if ( 'date' === $mysql_type ) { + $format_condition_sql = sprintf( + "%s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z?)?$'", + $value_sql_alias + ); + $normalized_value_sql = $date_part_sql; + $time_condition_sql = null; + } else { + $date_only_condition_sql = sprintf( + "%s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'", + $value_sql_alias + ); + $date_time_condition_sql = sprintf( + "%s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z?$'", + $value_sql_alias + ); + $format_condition_sql = sprintf( '(%s OR %s)', $date_only_condition_sql, $date_time_condition_sql ); + $normalized_value_sql = sprintf( + "CASE WHEN %1\$s THEN %2\$s || ' 00:00:00' ELSE %3\$s || ' ' || SUBSTRING(%2\$s FROM 12 FOR 8) END", + $date_only_condition_sql, + $value_sql_alias, + $date_part_sql + ); + $time_condition_sql = sprintf( + '(%1$s OR (CAST(SUBSTRING(%2$s FROM 12 FOR 2) AS integer) BETWEEN 0 AND 23 AND CAST(SUBSTRING(%2$s FROM 15 FOR 2) AS integer) BETWEEN 0 AND 59 AND CAST(SUBSTRING(%2$s FROM 18 FOR 2) AS integer) BETWEEN 0 AND 59))', + $date_only_condition_sql, + $value_sql_alias + ); + } + + $time_error_clause_sql = null === $time_condition_sql ? '' : sprintf( ' WHEN NOT %s THEN %s', $time_condition_sql, $error_sql ); + return sprintf( + '(SELECT CASE WHEN %1$s IS NULL THEN NULL WHEN NOT (%2$s) THEN %3$s%4$s ' . + 'WHEN %5$s = \'0000\' AND %6$s = \'00\' AND %7$s = \'00\' THEN %8$s ' . + 'WHEN %5$s <> \'0000\' AND (%6$s = \'00\' OR %7$s = \'00\') THEN %9$s ' . + 'WHEN %10$s THEN %11$s ELSE %3$s END FROM (SELECT CAST(%12$s AS text) AS "value") AS "__wp_pg_mysql_temporal_value")', + $value_sql_alias, + $format_condition_sql, + $error_sql, + $time_error_clause_sql, + $year_text_sql, + $month_text_sql, + $day_text_sql, + $reject_zero_date ? $error_sql : $normalized_value_sql, + $reject_zero_in_date ? $error_sql : $normalized_value_sql, + $this->get_postgresql_mysql_inline_valid_calendar_date_condition_sql( + $year_sql, + $month_sql, + $day_sql + ), + $normalized_value_sql, + $value_sql + ); + } + private function get_postgresql_mysql_inline_valid_calendar_date_condition_sql( string $year_sql, string $month_sql, string $day_sql ): string { + $leap_year_condition_sql = sprintf( + '((%1$s %% 4 = 0 AND %1$s %% 100 <> 0) OR %1$s %% 400 = 0)', + $year_sql + ); + $max_day_sql = sprintf( + 'CASE WHEN %1$s IN (1, 3, 5, 7, 8, 10, 12) THEN 31 WHEN %1$s IN (4, 6, 9, 11) THEN 30 WHEN %1$s = 2 THEN CASE WHEN %2$s THEN 29 ELSE 28 END ELSE 0 END', + $month_sql, + $leap_year_condition_sql + ); + return sprintf( + '(%1$s BETWEEN 1 AND 9999 AND %2$s BETWEEN 1 AND 12 AND %3$s BETWEEN 1 AND %4$s)', + $year_sql, + $month_sql, + $day_sql, + $max_day_sql + ); + } + private function get_mysql_intrinsically_valid_temporal_expression_sql_for_column( string $base_type, string $column_type, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( + $start < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( $end === $after_close ) { + return $this->get_mysql_intrinsically_valid_temporal_expression_sql_for_column( $base_type, $column_type, $tokens, $start + 1, $end - 1, $value_sql ); + } + } + + $function = $this->get_mysql_intrinsically_valid_temporal_function_name( $tokens, $start, $end ); + if ( null !== $function ) { + $function_sql = $this->get_mysql_intrinsically_valid_temporal_function_sql_for_column( $base_type, $function, $value_sql ); + if ( null !== $function_sql ) { + return $function_sql; + } + } + + foreach ( + array( + array( 'get_mysql_intrinsically_valid_date_function_expression_sql_for_column', array( $base_type, $tokens, $start, $end, $value_sql ) ), + array( 'get_mysql_intrinsically_valid_temporal_cast_expression_sql_for_column', array( $base_type, $column_type, $tokens, $start, $end, $value_sql ) ), + array( 'get_mysql_intrinsically_valid_from_unixtime_expression_sql_for_column', array( $base_type, $column_type, $tokens, $start, $end, $value_sql ) ), + array( 'get_mysql_intrinsically_valid_date_format_expression_sql_for_column', array( $base_type, $tokens, $start, $end, $value_sql ) ), + ) as list( $sql_helper, $arguments ) + ) { + $expression_sql = $this->$sql_helper( ...$arguments ); + if ( null !== $expression_sql ) { + return $expression_sql; + } + } + + if ( $this->is_mysql_intrinsically_valid_temporal_wrapper_expression( $tokens, $start, $end ) || $this->is_mysql_intrinsically_valid_temporal_case_expression( $tokens, $start, $end ) ) { + return $this->get_postgresql_mysql_known_valid_temporal_text_storage_expression_sql( $base_type, $value_sql ); + } + + if ( $this->is_mysql_intrinsically_valid_temporal_arithmetic_expression( $tokens, $start, $end ) ) { + return $this->get_postgresql_mysql_temporal_storage_expression_sql( $base_type, $column_type, $value_sql ); + } + + if ( $this->is_mysql_intrinsically_valid_temporal_text_expression( $tokens, $start, $end ) ) { + return $this->get_postgresql_mysql_known_valid_temporal_text_storage_expression_sql( $base_type, $value_sql ); + } + return null; + } + private function get_mysql_intrinsically_valid_temporal_function_sql_for_column( string $base_type, string $function_name, string $value_sql ): ?string { + $is_date_function = in_array( $function_name, array( 'curdate', 'utc_date' ), true ); + if ( 'date' === $base_type ) { + return $is_date_function ? $value_sql : null; + } + + if ( ! in_array( $base_type, array( 'datetime', 'timestamp' ), true ) ) { + return null; + } + + if ( $is_date_function ) { + return $this->get_postgresql_mysql_date_to_datetime_storage_expression_sql( $value_sql ); + } + return in_array( $function_name, array( 'now', 'utc_timestamp', 'localtime', 'localtimestamp' ), true ) ? $value_sql : null; + } + private function get_mysql_intrinsically_valid_temporal_function_name( array $tokens, int $start, int $end ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null !== $bounds && $bounds['close'] + 1 === $end ) { + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return null; + } + + $fsp = $this->get_mysql_temporal_function_fractional_seconds_precision( + array_map( + function ( array $argument ) use ( $tokens ): string { + return $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $argument['start'], + $argument['end'] + ); + }, + $arguments + ) + ); + + if ( in_array( $bounds['function'], array( 'curdate', 'utc_date' ), true ) ) { + return 0 === count( $arguments ) ? $bounds['function'] : null; + } + + return in_array( $bounds['function'], array( 'now', 'utc_timestamp', 'localtime', 'localtimestamp' ), true ) && null !== $fsp + ? $bounds['function'] + : null; + } + + if ( $start + 1 !== $end ) { + return null; + } + + $function_name = strtolower( $tokens[ $start ]->get_value() ); + if ( 'current_date' === $function_name && WP_MySQL_Lexer::IDENTIFIER === $tokens[ $start ]->id ) { + return 'curdate'; + } + + if ( + in_array( $function_name, array( 'current_timestamp', 'localtime', 'localtimestamp' ), true ) + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[ $start ]->id + ) { + return 'current_timestamp' === $function_name ? 'utc_timestamp' : $function_name; + } + return null; + } + private function get_mysql_intrinsically_valid_date_function_expression_sql_for_column( string $base_type, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + if ( $this->is_mysql_date_function_with_temporal_source( $tokens, $start, $end ) ) { + return 'date' === $base_type + ? $value_sql + : $this->get_postgresql_mysql_date_to_datetime_storage_expression_sql( $value_sql ); + } + return null; + } + private function get_mysql_intrinsically_valid_temporal_cast_expression_sql_for_column( string $base_type, string $column_type, array $tokens, int $start, int $end, string $value_sql ): ?string { + $temporal_cast = $this->get_mysql_intrinsically_valid_temporal_cast_expression_bounds( $tokens, $start, $end ); + if ( null === $temporal_cast ) { + return null; + } + + if ( 'date_time' === $temporal_cast['type'] ) { + return $this->get_postgresql_mysql_temporal_storage_expression_sql( $base_type, $column_type, $value_sql ); + } + return 'date' === $base_type + ? $value_sql + : $this->get_postgresql_mysql_date_to_datetime_storage_expression_sql( $value_sql ); + } + private function get_mysql_intrinsically_valid_from_unixtime_expression_sql_for_column( string $base_type, string $column_type, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( $this->is_mysql_null_from_unixtime_expression( $tokens, $start, $end ) ) { + return $value_sql; + } + + $timestamp_sql = $this->get_mysql_intrinsically_valid_from_unixtime_timestamp_sql( $tokens, $start, $end ); + if ( null !== $timestamp_sql ) { + return $this->get_postgresql_mysql_temporal_storage_expression_sql( $base_type, $column_type, $timestamp_sql ); + } + + $format = $this->get_mysql_intrinsically_valid_formatted_from_unixtime_expression_format( $tokens, $start, $end ); + if ( null === $format ) { + return null; + } + + return $this->get_mysql_intrinsically_valid_fixed_temporal_format_storage_expression_sql_for_column( $base_type, $format, $value_sql ); + } + private function get_mysql_intrinsically_valid_date_format_expression_sql_for_column( string $base_type, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( $this->is_mysql_null_date_format_expression( $tokens, $start, $end ) ) { + return $value_sql; + } + + $format = $this->get_mysql_intrinsically_valid_date_format_expression_format( $tokens, $start, $end ); + if ( null === $format ) { + return null; + } + + return $this->get_mysql_intrinsically_valid_fixed_temporal_format_storage_expression_sql_for_column( $base_type, $format, $value_sql ); + } + private function get_mysql_intrinsically_valid_fixed_temporal_format_storage_expression_sql_for_column( string $base_type, string $format, string $value_sql ): ?string { + if ( '%Y-%m-%d' === $format ) { + return 'date' === $base_type + ? $value_sql + : $this->get_postgresql_mysql_date_to_datetime_storage_expression_sql( $value_sql ); + } + + if ( '%Y-%m-%d %H:%i:%s' === $format ) { + return 'date' === $base_type + ? $this->get_postgresql_mysql_known_valid_temporal_text_storage_expression_sql( $base_type, $value_sql ) + : $value_sql; + } + return null; + } + private function is_mysql_intrinsically_valid_temporal_wrapper_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || $bounds['close'] + 1 !== $end ) { + return false; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return false; + } + + if ( 'if' === $bounds['function'] ) { + $result_arguments = 3 === count( $arguments ) ? array_slice( $arguments, 1 ) : null; + } elseif ( in_array( $bounds['function'], array( 'coalesce', 'greatest', 'ifnull', 'least' ), true ) ) { + $result_arguments = count( $arguments ) >= 2 ? $arguments : null; + } elseif ( 'nullif' === $bounds['function'] ) { + $result_arguments = 2 === count( $arguments ) ? array( $arguments[0] ) : null; + } else { + return false; + } + + if ( null === $result_arguments ) { + return false; + } + + return $this->are_mysql_intrinsically_valid_temporal_result_ranges( $tokens, $result_arguments ); + } + private function is_mysql_intrinsically_valid_temporal_case_expression( array $tokens, int $start, int $end ): bool { + $case_expression = $this->get_mysql_case_expression_descriptor( $tokens, $start, $end ); + + return null !== $case_expression + && $case_expression['end'] === $end + && $this->are_mysql_intrinsically_valid_temporal_result_ranges( $tokens, $case_expression['result_ranges'] ); + } + private function are_mysql_intrinsically_valid_temporal_result_ranges( array $tokens, array $ranges ): bool { + $has_ranges = ! empty( $ranges ); + foreach ( $ranges as $range ) { + if ( + ! $this->is_mysql_intrinsically_valid_temporal_source_expression( $tokens, $range['start'], $range['end'] ) + && ! $this->is_mysql_date_function_with_temporal_source( $tokens, $range['start'], $range['end'] ) + ) { + return false; + } + } + return $has_ranges; + } + private function get_mysql_case_expression_descriptor( array $tokens, int $start, int $end ): ?array { + if ( + $start >= $end + || ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::CASE_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + + $branches = array(); + $case_depth = 0; + $paren_depth = 0; + $value_start = $start + 1; + $value_end = null; + $test_start = null; + $test_end = null; + $result_start = null; + $else_start = null; + + $finalize_current_branch = function ( int $position ) use ( &$branches, &$test_start, &$test_end, &$result_start ): bool { + if ( + null === $test_start + || null === $test_end + || null === $result_start + || $test_start >= $test_end + || $result_start >= $position + ) { + return false; + } + $branches[] = array( + 'test_start' => $test_start, + 'test_end' => $test_end, + 'result_start' => $result_start, + 'result_end' => $position, + ); + $test_start = null; + $test_end = null; + $result_start = null; + return true; + }; + + for ( $i = $start + 1; $i < $end; $i++ ) { + $token_id = $tokens[ $i ]->id; + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token_id ) { + ++$paren_depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token_id ) { + --$paren_depth; + if ( $paren_depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $paren_depth ) { + continue; + } + + if ( WP_MySQL_Lexer::CASE_SYMBOL === $token_id ) { + ++$case_depth; + continue; + } + + if ( WP_MySQL_Lexer::END_SYMBOL === $token_id && $case_depth > 0 ) { + --$case_depth; + continue; + } + + if ( 0 !== $case_depth ) { + continue; + } + + if ( WP_MySQL_Lexer::WHEN_SYMBOL === $token_id ) { + if ( null !== $else_start ) { + return null; + } + if ( null === $value_end ) { + $value_end = $i; + } elseif ( null !== $result_start ) { + if ( ! $finalize_current_branch( $i ) ) { + return null; + } + } + if ( null !== $test_start || null !== $test_end ) { + return null; + } + $test_start = $i + 1; + continue; + } + + if ( WP_MySQL_Lexer::THEN_SYMBOL === $token_id ) { + if ( + null === $test_start + || null !== $test_end + || null !== $result_start + || null !== $else_start + || $test_start >= $i + ) { + return null; + } + $test_end = $i; + $result_start = $i + 1; + continue; + } + + if ( WP_MySQL_Lexer::ELSE_SYMBOL === $token_id ) { + if ( null !== $else_start || null === $result_start || ! $finalize_current_branch( $i ) ) { + return null; + } + $else_start = $i + 1; + continue; + } + + if ( WP_MySQL_Lexer::END_SYMBOL === $token_id ) { + if ( null === $value_end ) { + return null; + } + if ( null !== $result_start && ! $finalize_current_branch( $i ) ) { + return null; + } + if ( null !== $test_start || null !== $test_end || empty( $branches ) ) { + return null; + } + if ( null !== $else_start && $else_start >= $i ) { + return null; + } + $value_is_empty = $value_start >= $value_end; + $starts_at_when = isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::WHEN_SYMBOL === $tokens[ $start + 1 ]->id; + if ( $value_is_empty !== $starts_at_when ) { + return null; + } + $result_ranges = array(); + foreach ( $branches as $branch ) { + $result_ranges[] = $this->get_mysql_argument_range( $branch['result_start'], $branch['result_end'] ); + } + $else = null === $else_start ? null : $this->get_mysql_argument_range( $else_start, $i ); + if ( null !== $else ) { + $result_ranges[] = $else; + } + return array( + 'value_start' => $value_start, + 'value_end' => $value_end, + 'simple' => ! $value_is_empty, + 'branches' => $branches, + 'else' => $else, + 'result_ranges' => $result_ranges, + 'end' => $i + 1, + ); + } + } + return null; + } + private function is_mysql_date_function_with_temporal_source( array $tokens, int $start, int $end ): bool { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || 'date' !== $bounds['function'] || $bounds['close'] + 1 !== $end ) { + return false; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return false; + } + return $this->is_mysql_intrinsically_valid_temporal_source_expression( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + } + private function is_mysql_intrinsically_valid_temporal_arithmetic_expression( array $tokens, int $start, int $end ): bool { + foreach ( array( 'get_mysql_date_arithmetic_function_bounds', 'get_mysql_infix_interval_expression_bounds' ) as $arithmetic_bounds_method ) { + $arithmetic = $this->{$arithmetic_bounds_method}( $tokens, $start, $end ); + if ( null !== $arithmetic && $arithmetic['close'] + 1 === $end ) { + return $this->is_mysql_intrinsically_valid_temporal_source_expression( $tokens, $arithmetic['expression_start'], $arithmetic['expression_end'] ); + } + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( + null === $bounds + || $bounds['close'] + 1 !== $end + || 'timestampadd' !== $bounds['function'] + ) { + return false; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 3 !== count( $arguments ) ) { + return false; + } + + if ( + null === $this->get_mysql_timestampadd_interval( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + $arguments[1]['start'], + $arguments[1]['end'] + ) + ) { + return false; + } + return $this->is_mysql_intrinsically_valid_temporal_source_expression( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'] + ); + } + private function is_mysql_intrinsically_valid_temporal_source_expression( array $tokens, int $start, int $end ): bool { + if ( + $start < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( $end === $after_close ) { + return $this->is_mysql_intrinsically_valid_temporal_source_expression( $tokens, $start + 1, $end - 1 ); + } + } + + $temporal_source_rules = array( + array( 'get_mysql_intrinsically_valid_temporal_function_name', true ), + array( 'is_mysql_null_literal_expression', false ), + array( 'is_mysql_intrinsically_valid_temporal_literal_expression', false ), + array( 'is_mysql_intrinsically_valid_temporal_text_expression', false ), + array( 'is_mysql_intrinsically_valid_temporal_wrapper_expression', false ), + array( 'is_mysql_date_function_with_temporal_source', false ), + array( 'get_mysql_intrinsically_valid_temporal_cast_expression_bounds', true ), + array( 'get_mysql_intrinsically_valid_from_unixtime_timestamp_sql', true ), + array( 'is_mysql_null_from_unixtime_expression', false ), + array( 'get_mysql_intrinsically_valid_formatted_from_unixtime_expression_format', true ), + array( 'is_mysql_null_date_format_expression', false ), + array( 'get_mysql_intrinsically_valid_date_format_expression_format', true ), + array( 'is_mysql_intrinsically_valid_temporal_case_expression', false ), + ); + + foreach ( $temporal_source_rules as $rule ) { + $result = $this->{$rule[0]}( $tokens, $start, $end ); + if ( $rule[1] ? null !== $result : $result ) { + return true; + } + } + return $this->is_mysql_intrinsically_valid_temporal_arithmetic_expression( $tokens, $start, $end ); + } + private function get_mysql_intrinsically_valid_temporal_cast_expression_bounds( array $tokens, int $start, int $end ): ?array { + $temporal_cast = $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $start, + $end, + array( + 'cast' => array( 'date_time', 'date' ), + 'convert' => array( 'date' ), + ) + ); + if ( null === $temporal_cast || $temporal_cast['close'] + 1 !== $end ) { + return null; + } + return $this->is_mysql_intrinsically_valid_temporal_source_expression( + $tokens, + $temporal_cast['expression_start'], + $temporal_cast['expression_end'] + ) ? $temporal_cast : null; + } + private function is_mysql_null_literal_expression( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id; + } + private function is_mysql_intrinsically_valid_temporal_literal_expression( array $tokens, int $start, int $end ): bool { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || 'string' !== $literal['type'] || null === $literal['value'] ) { + return false; + } + return $this->is_mysql_intrinsically_valid_temporal_value( $literal['value'] ); + } + private function is_mysql_intrinsically_valid_temporal_text_expression( array $tokens, int $start, int $end ): bool { + $constant_value = $this->get_mysql_constant_string_expression_value( $tokens, $start, $end ); + if ( null === $constant_value ) { + return false; + } + return $constant_value['is_null'] + || ( + ( + 1 === preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $constant_value['value'] ) + || 1 === preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $constant_value['value'] ) + ) + && $this->is_mysql_intrinsically_valid_temporal_value( $constant_value['value'] ) + ); + } + private function get_mysql_constant_string_expression_value( array $tokens, int $start, int $end ): ?array { + if ( + $start < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( $end === $after_close ) { + return $this->get_mysql_constant_string_expression_value( $tokens, $start + 1, $end - 1 ); + } + } + if ( $this->is_mysql_null_literal_expression( $tokens, $start, $end ) ) { + return $this->get_mysql_null_constant_string_value(); + } + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return $this->get_mysql_constant_string_value( $tokens[ $start ]->get_value() ); + } + $trim_bounds = $this->get_mysql_trim_function_bounds( $tokens, $start, $end ); + if ( null !== $trim_bounds && $trim_bounds['close'] + 1 === $end && null !== $trim_bounds['remove'] ) { + $value = $this->get_mysql_constant_string_expression_value( $tokens, $trim_bounds['argument_start'], $trim_bounds['argument_end'] ); + if ( null === $value || $value['is_null'] ) { + return $value; + } + $remove = $trim_bounds['remove']; + $trimmed_value = $value['value']; + if ( '' !== $remove ) { + $remove_length = strlen( $remove ); + if ( in_array( $trim_bounds['direction'], array( 'both', 'leading' ), true ) ) { + while ( 0 === strncmp( $trimmed_value, $remove, $remove_length ) ) { + $trimmed_value = substr( $trimmed_value, $remove_length ); + } + } + + if ( in_array( $trim_bounds['direction'], array( 'both', 'trailing' ), true ) ) { + while ( substr( $trimmed_value, -$remove_length ) === $remove ) { + $trimmed_value = substr( $trimmed_value, 0, -$remove_length ); + } + } + } + return $this->get_mysql_constant_string_value( $trimmed_value ); + } + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || $bounds['close'] + 1 !== $end ) { + return null; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || empty( $arguments ) ) { + return null; + } + return $this->get_mysql_constant_string_function_value( $tokens, $arguments, $bounds['function'] ); + } + private function get_mysql_constant_string_function_value( array $tokens, array $arguments, string $function_name ): ?array { + $count = count( $arguments ); + $descriptor = null; + foreach ( self::MYSQL_CONSTANT_STRING_FUNCTION_DESCRIPTORS as $constant_string_function_descriptor ) { + if ( $this->is_mysql_common_function_descriptor_match( $function_name, $count, $constant_string_function_descriptor[0], $constant_string_function_descriptor[1], $constant_string_function_descriptor[2] ) ) { + $descriptor = $constant_string_function_descriptor; + break; + } + } + if ( null === $descriptor ) { + return null; + } + $operation = $descriptor[3]; + $handler = $descriptor[4]; + switch ( $operation ) { + case 'ascii_unary': + $value = $this->get_mysql_ascii_constant_string_argument_value( $tokens, $arguments[0] ); + if ( null === $value || $value['is_null'] ) { + return $value; + } + if ( 'ltrim' === $handler || 'rtrim' === $handler ) { + return $this->get_mysql_constant_string_value( $handler( $value['value'], ' ' ) ); + } + return $this->get_mysql_constant_string_value( $handler( $value['value'] ) ); + case 'coalesce': + foreach ( $arguments as $argument ) { + $value = $this->get_mysql_constant_string_argument_value( $tokens, $argument ); + if ( null === $value || ! $value['is_null'] ) { + return $value; + } + } + return $this->get_mysql_null_constant_string_value(); + case 'ifnull': + $left = $this->get_mysql_constant_string_argument_value( $tokens, $arguments[0] ); + return null === $left || ! $left['is_null'] ? $left : $this->get_mysql_constant_string_argument_value( $tokens, $arguments[1] ); + case 'elt': + $index = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[0] ); + return null === $index ? null : ( $index < 1 || $index >= $count ? $this->get_mysql_null_constant_string_value() : $this->get_mysql_constant_string_argument_value( $tokens, $arguments[ $index ] ) ); + case 'strings': + $values = $this->get_mysql_constant_string_argument_values( $tokens, $arguments ); + if ( null === $values ) { + return null; + } + if ( 'concat_ws' === $handler ) { + $separator = array_shift( $values ); + if ( $separator['is_null'] ) { + return $this->get_mysql_null_constant_string_value(); + } + $parts = array(); + foreach ( $values as $part ) { + if ( ! $part['is_null'] ) { + $parts[] = $part['value']; + } + } + return $this->get_mysql_constant_string_value( implode( $separator['value'], $parts ) ); + } + if ( 'nullif' === $handler ) { + return ! $values[0]['is_null'] && ! $values[1]['is_null'] && $values[0]['value'] === $values[1]['value'] ? $this->get_mysql_null_constant_string_value() : $values[0]; + } + if ( in_array( true, array_column( $values, 'is_null' ), true ) ) { + return $this->get_mysql_null_constant_string_value(); + } + $strings = array_column( $values, 'value' ); + return 'replace' === $handler + ? $this->get_mysql_constant_string_value( str_replace( $strings[1], $strings[2], $strings[0] ) ) + : $this->get_mysql_constant_string_value( implode( '', $strings ) ); + case 'side': + case 'repeat': + case 'substring': + $value = $this->get_mysql_ascii_constant_string_argument_value( $tokens, $arguments[0] ); + if ( null === $value || $value['is_null'] ) { + return $value; + } + if ( 'repeat' === $operation ) { + $count_value = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[1] ); + return null === $count_value ? null : $this->get_mysql_constant_string_value( $count_value <= 0 ? '' : str_repeat( $value['value'], $count_value ) ); + } + $position = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[1] ); + if ( null === $position || ( 'substring' === $operation && $position < 1 ) ) { + return null; + } + if ( 'side' === $operation ) { + return $this->get_mysql_constant_substring_value( $value['value'], 'left' === $function_name || $position <= 0 ? 0 : -$position, max( $position, 0 ) ); + } + if ( 2 === $count ) { + return $this->get_mysql_constant_substring_value( $value['value'], $position - 1 ); + } + $length = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[2] ); + return null === $length ? null : $this->get_mysql_constant_substring_value( $value['value'], $position - 1, max( $length, 0 ) ); + case 'pad': + $values = $this->get_mysql_constant_string_argument_values( $tokens, array( $arguments[0], $arguments[2] ) ); + if ( null === $values ) { + return null; + } + if ( in_array( true, array_column( $values, 'is_null' ), true ) ) { + return $this->get_mysql_null_constant_string_value(); + } + list( $value, $pad ) = array_column( $values, 'value' ); + $length = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[1] ); + if ( null === $length || $length < 0 || '' === $pad || ! $this->is_mysql_ascii_constant_string_value( $value ) || ! $this->is_mysql_ascii_constant_string_value( $pad ) ) { + return null; + } + $padded = substr( str_pad( $value, max( $length, strlen( $value ) ), $pad, 'lpad' === $function_name ? STR_PAD_LEFT : STR_PAD_RIGHT ), 0, $length ); + return $this->get_mysql_constant_string_value( false === $padded ? '' : $padded ); + case 'space': + $count_value = $this->get_mysql_constant_php_integer_argument_value( $tokens, $arguments[0] ); + return null === $count_value ? null : $this->get_mysql_constant_string_value( str_repeat( ' ', max( $count_value, 0 ) ) ); + } + return null; + } + private function get_mysql_constant_string_argument_values( array $tokens, array $arguments ): ?array { + $values = array(); + foreach ( $arguments as $argument ) { + $value = $this->get_mysql_constant_string_argument_value( $tokens, $argument ); + if ( null === $value ) { + return null; + } + $values[] = $value; + } + return $values; + } + private function get_mysql_constant_string_argument_value( array $tokens, array $argument ): ?array { + return $this->get_mysql_constant_string_expression_value( $tokens, $argument['start'], $argument['end'] ); + } + private function get_mysql_constant_php_integer_argument_value( array $tokens, array $argument ): ?int { + return $this->get_mysql_constant_php_integer_expression_value( $tokens, $argument['start'], $argument['end'] ); + } + private function get_mysql_ascii_constant_string_argument_value( array $tokens, array $argument ): ?array { + $value = $this->get_mysql_constant_string_argument_value( $tokens, $argument ); + return null === $value || $value['is_null'] || $this->is_mysql_ascii_constant_string_value( $value['value'] ) ? $value : null; + } + private function get_mysql_constant_substring_value( string $value, int $start, ?int $length = null ): array { + $substring_value = null === $length ? substr( $value, $start ) : substr( $value, $start, $length ); + return $this->get_mysql_constant_string_value( false === $substring_value ? '' : $substring_value ); + } + private function get_mysql_null_constant_string_value(): array { + return array( + 'is_null' => true, + 'value' => '', + ); + } + private function get_mysql_constant_string_value( string $value ): array { + return array( + 'is_null' => false, + 'value' => $value, + ); + } + private function get_mysql_trim_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::TRIM_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 || 1 !== count( $arguments ) ) { + return null; + } + + $argument = $arguments[0]; + $bounds = array( + 'direction' => 'both', + 'remove' => ' ', + 'remove_start' => null, + 'remove_end' => null, + 'argument_start' => $argument['start'], + 'argument_end' => $argument['end'], + 'close' => $after_close - 1, + ); + $directions = array( + WP_MySQL_Lexer::BOTH_SYMBOL, + WP_MySQL_Lexer::LEADING_SYMBOL, + WP_MySQL_Lexer::TRAILING_SYMBOL, + ); + $from = $this->find_first_top_level_mysql_token( + $tokens, + array( WP_MySQL_Lexer::FROM_SYMBOL ), + $argument['start'], + $argument['end'] + ); + + if ( null === $from ) { + return null === $this->find_first_top_level_mysql_token( $tokens, $directions, $argument['start'], $argument['end'] ) ? $bounds : null; + } + + if ( $from + 1 >= $argument['end'] ) { + return null; + } + + $prefix_start = $argument['start']; + if ( $prefix_start < $from ) { + if ( ! isset( $tokens[ $prefix_start ] ) ) { + return null; + } + + if ( in_array( $tokens[ $prefix_start ]->id, $directions, true ) ) { + $bounds['direction'] = strtolower( $tokens[ $prefix_start ]->get_value() ); + ++$prefix_start; + } + + if ( $prefix_start < $from ) { + $bounds['remove'] = null; + if ( $this->is_mysql_string_literal_range( $tokens, $prefix_start, $from ) ) { + $bounds['remove'] = $tokens[ $prefix_start ]->get_value(); + } else { + $bounds['remove_start'] = $prefix_start; + $bounds['remove_end'] = $from; + } + } + } + $bounds['argument_start'] = $from + 1; + return $bounds; + } + private function get_mysql_constant_php_integer_expression_value( array $tokens, int $start, int $end ): ?int { + $position = $start; + $value = $this->parse_mysql_constant_integer_expression( $tokens, $position, $end ); + return null !== $value && $position === $end ? $value : null; + } + private function is_mysql_ascii_constant_string_value( string $value ): bool { + return 1 === preg_match( '/^[\x00-\x7F]*$/', $value ); + } + private function is_mysql_intrinsically_valid_temporal_value( string $value ): bool { + $datetime_value = $this->normalize_mysql_dml_datetime_literal_format( $value ); + $datetime_parts = $this->get_mysql_dml_datetime_parts( $datetime_value ); + if ( null !== $datetime_parts ) { + return $this->is_mysql_dml_time_value_valid( $datetime_parts['hour'], $datetime_parts['minute'], $datetime_parts['second'] ) + && $this->is_mysql_real_calendar_date_parts( $datetime_parts['year'], $datetime_parts['month'], $datetime_parts['day'] ); + } + + $date_value = $this->normalize_mysql_dml_date_literal_format( $value ); + $date_parts = $this->get_mysql_dml_date_parts( $date_value ); + if ( null === $date_parts ) { + return false; + } + return $this->is_mysql_real_calendar_date_parts( $date_parts['year'], $date_parts['month'], $date_parts['day'] ); + } + private function get_mysql_intrinsically_valid_formatted_from_unixtime_expression_format( array $tokens, int $start, int $end ): ?string { + $arguments = $this->get_mysql_from_unixtime_call_arguments( $tokens, $start, $end ); + if ( + null === $arguments + || 2 !== count( $arguments ) + || ! $this->is_mysql_supported_from_unixtime_timestamp_argument( $tokens, $arguments[0] ) + ) { + return null; + } + + $format = $this->get_mysql_constant_string_argument_value( $tokens, $arguments[1] ); + if ( null === $format || $format['is_null'] ) { + return null; + } + return in_array( $format['value'], array( '%Y-%m-%d', '%Y-%m-%d %H:%i:%s' ), true ) ? $format['value'] : null; + } + private function is_mysql_null_from_unixtime_expression( array $tokens, int $start, int $end ): bool { + $arguments = $this->get_mysql_from_unixtime_call_arguments( $tokens, $start, $end ); + if ( null === $arguments || ! in_array( count( $arguments ), array( 1, 2 ), true ) ) { + return false; + } + return $this->is_mysql_null_literal_expression( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + } + private function is_mysql_real_calendar_date_parts( string $year, string $month, string $day ): bool { + if ( '0000' === $year || '00' === $month || '00' === $day ) { + return false; + } + return checkdate( (int) $month, (int) $day, (int) $year ); + } + private function get_mysql_intrinsically_valid_date_format_expression_format( array $tokens, int $start, int $end ): ?string { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $start, $end ); + if ( + null === $bounds + || $bounds['close'] + 1 !== $end + || ! in_array( $bounds['format'], array( '%Y-%m-%d', '%Y-%m-%d %H:%i:%s' ), true ) + ) { + return null; + } + + if ( ! $this->is_mysql_intrinsically_valid_temporal_source_expression( $tokens, $bounds['expression_start'], $bounds['expression_end'] ) ) { + return null; + } + return $bounds['format']; + } + private function is_mysql_null_date_format_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $start, $end ); + if ( null === $bounds || $bounds['close'] + 1 !== $end ) { + return false; + } + return $this->is_mysql_null_literal_expression( $tokens, $bounds['expression_start'], $bounds['expression_end'] ); + } + private function get_mysql_intrinsically_valid_from_unixtime_timestamp_sql( array $tokens, int $start, int $end ): ?string { + $arguments = $this->get_mysql_from_unixtime_call_arguments( $tokens, $start, $end ); + if ( + null === $arguments + || 1 !== count( $arguments ) + || ! $this->is_mysql_supported_from_unixtime_timestamp_argument( $tokens, $arguments[0] ) + ) { + return null; + } + + $timestamp_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + return $this->get_postgresql_mysql_from_unixtime_timestamp_sql( $timestamp_sql ); + } + private function get_mysql_from_unixtime_call_arguments( array $tokens, int $start, int $end ): ?array { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || 'from_unixtime' !== $bounds['function'] || $bounds['close'] + 1 !== $end ) { + return null; + } + return $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + } + private function is_mysql_supported_from_unixtime_timestamp_argument( array $tokens, array $argument ): bool { + $literal = $this->parse_mysql_numeric_literal( $tokens, $argument['start'], $argument['end'] ); + if ( null === $literal || $literal['start'] !== $argument['start'] || $literal['end'] !== $argument['end'] ) { + return false; + } + + $timestamp = (float) $this->get_mysql_token_sequence_bytes( $tokens, $literal['start'], $literal['end'] ); + return $timestamp >= 0 && $timestamp <= 2147483647.999999; + } + private function get_postgresql_mysql_temporal_storage_expression_sql( string $base_type, string $column_type, string $value_sql ): string { + if ( 'date' === $base_type ) { + return sprintf( 'TO_CHAR(%s, %s)', $value_sql, $this->connection->quote( 'YYYY-MM-DD' ) ); + } + + $fsp = $this->get_mysql_column_type_display_width( $column_type ); + $fsp = null !== $fsp && $fsp >= 0 && $fsp <= 6 ? $fsp : 0; + if ( 0 === $fsp ) { + return sprintf( 'TO_CHAR(%s, %s)', $value_sql, $this->connection->quote( 'YYYY-MM-DD HH24:MI:SS' ) ); + } + return sprintf( + 'LEFT(TO_CHAR(%s, %s), %d)', + $value_sql, + $this->connection->quote( 'YYYY-MM-DD HH24:MI:SS.US' ), + 20 + $fsp + ); + } + private function get_postgresql_mysql_date_to_datetime_storage_expression_sql( string $value_sql ): string { + return sprintf( '(%s || %s)', $value_sql, $this->connection->quote( ' 00:00:00' ) ); + } + private function get_postgresql_mysql_known_valid_temporal_text_storage_expression_sql( string $base_type, string $value_sql ): string { + $value_text_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + if ( 'date' === $base_type ) { + return sprintf( 'SUBSTRING(%s FROM 1 FOR 10)', $value_text_sql ); + } + return sprintf( + 'CASE WHEN %1$s ~ %2$s THEN %1$s || %3$s ELSE %1$s END', + $value_text_sql, + $this->connection->quote( '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' ), + $this->connection->quote( ' 00:00:00' ) + ); + } + private function is_mysql_default_value_token_sequence( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $start ]->id; + } + private function get_strict_mysql_dml_integer_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( + ! $this->is_mysql_strict_sql_mode_active() + || ! $this->is_mysql_integer_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) + ) { + return null; + } + + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = trim( $literal['value'] ); + $integer = $this->get_strict_mysql_dml_integer_literal_value( $value ); + if ( null === $integer ) { + throw new InvalidArgumentException( sprintf( "Incorrect integer value: '%s'", $value ) ); + } + + if ( ! $this->is_mysql_integer_value_in_column_range( $integer, (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + $this->throw_mysql_out_of_range_value( $value ); + } + + if ( + '' === $value + || 1 === preg_match( '/^[+-]?[0-9]+$/', $value ) + || 1 !== preg_match( '/^[+-]?(?:[0-9]+\.0+|[0-9]*\.0+)$/', $value ) + ) { + if ( 'boolean' === $literal['type'] ) { + return $integer; + } + return null; + } + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ) + ); + } + private function get_strict_mysql_dml_integer_literal_value( string $value ): ?string { + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + + if ( 1 !== preg_match( '/^[+-]?(?:(?:[0-9]+)(?:\.0+)?|(?:[0-9]*\.0+))$/', $value ) ) { + return null; + } + + $integer = $value; + $dot = strpos( $integer, '.' ); + if ( false !== $dot ) { + $integer = substr( $integer, 0, $dot ); + } + + if ( '' === $integer || '+' === $integer || '-' === $integer ) { + $integer .= '0'; + } + return $this->normalize_mysql_integer_string( $integer ); + } + private function normalize_mysql_integer_string( string $value ): string { + $value = trim( $value ); + $negative = false; + if ( isset( $value[0] ) && ( '+' === $value[0] || '-' === $value[0] ) ) { + $negative = '-' === $value[0]; + $value = substr( $value, 1 ); + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return '0'; + } + return $negative ? '-' . $value : $value; + } + private function is_mysql_integer_value_in_column_range( string $value, string $column_type ): bool { + $bounds = $this->get_mysql_integer_column_bounds( $column_type ); + if ( null === $bounds ) { + return true; + } + return $this->compare_mysql_integer_strings( $value, $bounds['min'] ) >= 0 + && $this->compare_mysql_integer_strings( $value, $bounds['max'] ) <= 0; + } + private function get_mysql_integer_column_bounds( string $column_type ): ?array { + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + $unsigned = false !== stripos( $column_type, 'unsigned' ); + + $signed_bounds = array( + 'int1' => array( '-128', '127' ), + 'int2' => array( '-32768', '32767' ), + 'int3' => array( '-8388608', '8388607' ), + 'int4' => array( '-2147483648', '2147483647' ), + 'int8' => array( '-9223372036854775808', '9223372036854775807' ), + 'tinyint' => array( '-128', '127' ), + 'smallint' => array( '-32768', '32767' ), + 'mediumint' => array( '-8388608', '8388607' ), + 'int' => array( '-2147483648', '2147483647' ), + 'integer' => array( '-2147483648', '2147483647' ), + 'bigint' => array( '-9223372036854775808', '9223372036854775807' ), + ); + $unsigned_max = array( + 'int1' => '255', + 'int2' => '65535', + 'int3' => '16777215', + 'int4' => '4294967295', + 'int8' => '18446744073709551615', + 'tinyint' => '255', + 'smallint' => '65535', + 'mediumint' => '16777215', + 'int' => '4294967295', + 'integer' => '4294967295', + 'bigint' => '18446744073709551615', + ); + + if ( ! isset( $signed_bounds[ $base_type ] ) ) { + return null; + } + + if ( $unsigned ) { + return array( + 'min' => '0', + 'max' => $unsigned_max[ $base_type ], + ); + } + return array( + 'min' => $signed_bounds[ $base_type ][0], + 'max' => $signed_bounds[ $base_type ][1], + ); + } + private function compare_mysql_integer_strings( string $left, string $right ): int { + $left = $this->normalize_mysql_integer_string( $left ); + $right = $this->normalize_mysql_integer_string( $right ); + + $left_negative = isset( $left[0] ) && '-' === $left[0]; + $right_negative = isset( $right[0] ) && '-' === $right[0]; + if ( $left_negative !== $right_negative ) { + return $left_negative ? -1 : 1; + } + + $left_digits = $left_negative ? substr( $left, 1 ) : $left; + $right_digits = $right_negative ? substr( $right, 1 ) : $right; + + if ( strlen( $left_digits ) !== strlen( $right_digits ) ) { + $result = strlen( $left_digits ) <=> strlen( $right_digits ); + return $left_negative ? -$result : $result; + } + + $result = strcmp( $left_digits, $right_digits ); + return $left_negative ? -$result : $result; + } + private function get_mysql_dml_year_literal_sql_for_column( array $tokens, int $start, int $end ): ?string { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = trim( $literal['value'] ); + $storage_year = $this->get_mysql_dml_year_storage_value( $value ); + if ( null === $storage_year ) { + $this->throw_mysql_incorrect_temporal_value( 'year', $value ); + } + return $this->connection->quote( $storage_year ); + } + private function get_mysql_dml_year_storage_value( string $value ): ?string { + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + + if ( 1 === preg_match( '/^([+-]?[0-9]+)(?:\.0+)?$/', $value, $matches ) ) { + $year = $this->normalize_mysql_integer_string( $matches[1] ); + } elseif ( 1 === preg_match( '/^([0-9]{4})-(?:[0-9]{2})-(?:[0-9]{2})(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z?)?$/', $value, $matches ) ) { + $year = $this->normalize_mysql_integer_string( $matches[1] ); + } else { + return null; + } + + if ( $this->compare_mysql_integer_strings( $year, '0' ) < 0 ) { + $this->throw_mysql_out_of_range_value( $value ); + } + + if ( '0' === $year ) { + return '0000'; + } + + if ( $this->compare_mysql_integer_strings( $year, '1' ) >= 0 && $this->compare_mysql_integer_strings( $year, '69' ) <= 0 ) { + return sprintf( '%04d', 2000 + (int) $year ); + } + + if ( $this->compare_mysql_integer_strings( $year, '70' ) >= 0 && $this->compare_mysql_integer_strings( $year, '99' ) <= 0 ) { + return (string) ( 1900 + (int) $year ); + } + + if ( $this->compare_mysql_integer_strings( $year, '1901' ) < 0 || $this->compare_mysql_integer_strings( $year, '2155' ) > 0 ) { + $this->throw_mysql_out_of_range_value( $value ); + } + return sprintf( '%04d', (int) $year ); + } + private function get_non_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + + $text_hex_sql = $this->get_mysql_text_hex_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $text_hex_sql ) { + return $text_hex_sql; + } + + $value_sql = $this->get_mysql_dml_date_time_literal_sql_for_column( $base_type, $tokens, $start, $end, false ); + if ( null !== $value_sql ) { + return $value_sql; + } + + $value_sql = $this->get_non_strict_mysql_dml_numeric_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 ); + } + private function get_mysql_text_hex_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_text_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + return null; + } + + $value = $this->get_mysql_text_hex_literal_value( $tokens, $start, $end ); + return null === $value ? null : $this->connection->quote( $value ); + } + private function get_mysql_text_hex_literal_value( array $tokens, int $start, int $end ): ?string { + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::HEX_NUMBER !== $tokens[ $start ]->id + ) { + return null; + } + + $bytes = $tokens[ $start ]->get_bytes(); + if ( 1 === preg_match( '/^0x([0-9a-fA-F]+)$/', $bytes, $matches ) ) { + $hex = $matches[1]; + } elseif ( 1 === preg_match( "/^[xX]'([0-9a-fA-F]*)'$/", $bytes, $matches ) ) { + $hex = $matches[1]; + } else { + return null; + } + + if ( 1 === strlen( $hex ) % 2 ) { + $hex = '0' . $hex; + } + + $decoded = hex2bin( $hex ); + return false === $decoded ? null : $decoded; + } + 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 ) + ); + } + private function get_non_strict_mysql_dml_numeric_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + if ( ! in_array( $base_type, array( 'decimal', 'double', 'float', 'numeric', 'real' ), true ) ) { + return null; + } + + if ( ! $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return null; + } + + if ( 1 === preg_match( '/^[[:space:]]*[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?[[:space:]]*$/', $tokens[ $start ]->get_value() ) ) { + return null; + } + return $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $start ] ) + ); + } + private function normalize_mysql_dml_date_literal_format( string $value ): string { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z?)?$/', $value, $matches ) ) { + return $matches[1]; + } + return $value; + } + private function normalize_mysql_dml_datetime_literal_format( string $value ): string { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(?:\.[0-9]+)?Z?$/', $value, $matches ) ) { + return $matches[1] . ' ' . $matches[2]; + } + return $value; + } + 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_sql_mode_active( 'NO_ZERO_IN_DATE' ); + } + 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], + ); + } + 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], + ); + } + 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; + } + 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 ) + ); + } + 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; + } + private function get_mysql_dml_column_names_from_metadata( array $metadata ): ?array { + $columns = array(); + $seen = array(); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' === $column_name ) { + return null; + } + + $column_key = strtolower( $column_name ); + if ( isset( $seen[ $column_key ] ) ) { + return null; + } + + $columns[] = $column_name; + $seen[ $column_key ] = true; + } + return count( $columns ) > 0 ? $columns : null; + } + private function get_mysql_dml_column_metadata( string $table_name ): array { + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + return $this->get_mysql_table_catalog_column_metadata_rows( $table_schema, $table_name ); + } + private function get_mysql_unqualified_dml_table_backend_schema( string $table_name ): string { + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + if ( 'public' !== $table_schema ) { + return $table_schema; + } + + if ( + 0 !== strcasecmp( $this->db_name, $this->main_db_name ) + && 0 !== strcasecmp( $this->db_name, 'public' ) + && 0 !== strcasecmp( $this->db_name, 'information_schema' ) + && ! $this->is_postgresql_internal_schema( $this->db_name ) + ) { + return $this->db_name; + } + return $table_schema; + } + private function get_postgresql_unqualified_dml_table_reference_sql( string $table_name ): string { + return $this->get_postgresql_table_identifier_sql( + $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ), + $table_name + ); + } + private function get_postgresql_table_identifier_sql( string $table_schema, string $table_name ): string { + if ( 'public' !== $table_schema ) { + return $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + } + return $this->connection->quote_identifier( $table_name ); + } + private function get_non_strict_dml_default_sql_for_column( array $column_metadata ): ?string { + if ( 'NO' !== strtoupper( (string) ( $column_metadata['is_nullable'] ?? '' ) ) ) { + return null; + } + + if ( $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + return null; + } + + $default_sql = $this->get_mysql_dml_default_sql_from_metadata( $column_metadata ); + if ( null !== $default_sql ) { + return $default_sql; + } + return $this->get_mysql_implicit_dml_default_sql( (string) ( $column_metadata['column_type'] ?? '' ) ); + } + private function get_mysql_dml_default_sql_from_metadata( array $column_metadata ): ?string { + if ( null === ( $column_metadata['column_default'] ?? null ) ) { + return null; + } + + $default = (string) $column_metadata['column_default']; + if ( + $this->is_mysql_current_timestamp_default_metadata( $default ) + || $this->mysql_column_extra_has_default_generated( (string) ( $column_metadata['extra'] ?? '' ) ) + ) { + $translated_default = $this->translate_mysql_default_fragment( $default ); + if ( null !== $translated_default ) { + return $translated_default['sql']; + } + } + return $this->connection->quote( $default ); + } + private function is_mysql_auto_increment_column_metadata( array $column_metadata ): bool { + return 'auto_increment' === strtolower( (string) ( $column_metadata['extra'] ?? '' ) ); + } + 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, self::MYSQL_IMPLICIT_DML_EMPTY_STRING_DEFAULT_BASE_TYPES, true ) ) { + return $this->connection->quote( '' ); + } + + if ( in_array( $base_type, self::MYSQL_IMPLICIT_DML_ZERO_DEFAULT_BASE_TYPES, 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; + } + 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 ); + } + private function get_mysql_column_type_display_width( string $column_type ): ?int { + if ( 1 !== preg_match( '/\(([0-9]+)\)/', $column_type, $matches ) ) { + return null; + } + return (int) $matches[1]; + } + 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; + } + private function is_mysql_strict_sql_mode_active(): bool { + return $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) + || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' ); + } + private function get_mysql_top_level_select_parts( string $query, int $statement_start = 1, ?array &$query_context = null ): ?array { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, $statement_start ); + return null === $statement_end ? null : array( + 'tokens' => $tokens, + 'statement_end' => $statement_end, + ); + } + private function translate_mysql_last_insert_id_assignment_select_query( string $query, ?array &$query_context = null ): ?array { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::FROM_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ) + ) + ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $statement_end ); + if ( null === $projection_ranges ) { + return null; + } + + $has_assignment = false; + foreach ( $projection_ranges as $projection_range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $projection_range['start'], + $projection_range['end'] + ); + if ( null === $expression_bounds ) { + return null; + } + + $assignment_value = $this->get_mysql_last_insert_id_assignment_literal_value( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( null !== $assignment_value ) { + $has_assignment = true; + continue; + } + + for ( $position = $expression_bounds['start']; $position < $expression_bounds['end']; $position++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $expression_bounds['end'] ); + if ( null === $bounds || 'last_insert_id' !== $bounds['function'] ) { + continue; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 0 !== count( $arguments ) ) { + return null; + } + } + } + + if ( ! $has_assignment ) { + return null; + } + + $previous_translation_enabled = $this->mysql_last_insert_id_assignment_translation_enabled; + $previous_assignment_value = $this->mysql_last_insert_id_assignment_value; + + $this->mysql_last_insert_id_assignment_translation_enabled = true; + $this->mysql_last_insert_id_assignment_value = null; + + try { + $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); + $last_insert_id = $this->mysql_last_insert_id_assignment_value; + } finally { + $this->mysql_last_insert_id_assignment_translation_enabled = $previous_translation_enabled; + $this->mysql_last_insert_id_assignment_value = $previous_assignment_value; + } + + if ( null === $last_insert_id ) { + return null; + } + return array( + 'sql' => $sql, + 'last_insert_id' => $last_insert_id, + ); + } + private function get_mysql_last_insert_id_assignment_literal_value( array $tokens, int $start, int $end ): ?int { + $normalized = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $normalized['start']; + $end = $normalized['end']; + + $argument = $this->get_mysql_last_insert_id_argument_range( $tokens, $start, $end ); + if ( null === $argument ) { + return null; + } + + $literal_start = $argument['start']; + $literal_end = $argument['end']; + if ( $literal_start + 2 === $literal_end && WP_MySQL_Lexer::PLUS_OPERATOR === ( $tokens[ $literal_start ]->id ?? null ) ) { + ++$literal_start; + } + + if ( + $literal_start + 1 !== $literal_end + || ! isset( $tokens[ $literal_start ] ) + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $literal_start ] ) + ) { + return null; + } + + $value = $tokens[ $literal_start ]->get_value(); + if ( '' === $value || ! ctype_digit( $value ) ) { + return null; + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return 0; + } + + $max = (string) PHP_INT_MAX; + if ( strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) ) { + return null; + } + return (int) $value; + } + private function get_mysql_last_insert_id_argument_range( array $tokens, int $start, int $end ): ?array { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( + null === $bounds + || 'last_insert_id' !== $bounds['function'] + || $bounds['close'] + 1 !== $end + ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + return null !== $arguments && 1 === count( $arguments ) ? $arguments[0] : null; + } + private function translate_mysql_version_function_select_query( string $query, ?array &$query_context = null ): ?string { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $statement_end ); + if ( null === $projection_ranges || 1 !== count( $projection_ranges ) ) { + return null; + } + + $projection = $projection_ranges[0]; + $bounds = $this->get_mysql_common_function_bounds( $tokens, $projection['start'], $projection['end'] ); + if ( + null === $bounds + || 'version' !== $bounds['function'] + || $bounds['close'] + 1 !== $projection['end'] + ) { + return null; + } + return sprintf( + 'SELECT %s AS %s', + $this->connection->quote( $this->get_mysql_version_string() ), + $this->connection->quote_identifier( 'VERSION()' ) + ); + } + private function translate_simple_mysql_select_query( string $query, ?array &$query_context = null ): ?string { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $context = $this->get_simple_mysql_single_table_select_context( $select['tokens'], $select['statement_end'], false, false, $query_context ); + if ( null === $context ) { + return null; + } + return $this->build_simple_mysql_single_table_select_sql( $select['tokens'], $context ); + } + private function get_simple_mysql_single_table_select_context( + array $tokens, + int $statement_end, + bool $skip_where_validation = false, + bool $reject_direct_information_schema_source = false, + ?array &$query_context = null + ): ?array { + if ( $this->contains_top_level_mysql_context_token( $tokens, $query_context, 1, $statement_end, self::MYSQL_SIMPLE_SELECT_UNSUPPORTED_TOKENS ) ) { + return null; + } + + $select_end = $statement_end; + $limit_position = $this->find_top_level_mysql_context_token( $tokens, $query_context, 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_context_token( $tokens, $query_context, WP_MySQL_Lexer::FROM_SYMBOL, 1, $select_end ); + if ( + null === $from_position + || 1 === $from_position + || ! $this->is_supported_simple_select_projection( $tokens, 1, $from_position ) + ) { + return null; + } + + $source_end = $this->find_first_top_level_mysql_context_token( + $tokens, + $query_context, + array( WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::WHERE_SYMBOL ), + $from_position + 1, + $select_end + ) ?? $select_end; + if ( + $reject_direct_information_schema_source + && $this->direct_information_schema_source_range_references_information_schema( $tokens, $from_position + 1, $source_end ) + ) { + return null; + } + + $table_reference_start = $from_position + 1; + $table_name_end = $table_reference_start; + $table_name_for_sql = $this->parse_mysql_main_database_table_name( $tokens, $table_name_end ); + $position = $table_reference_start; + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $source_end ); + if ( + null === $table_name_for_sql + || null === $table_reference + || $table_name_for_sql !== $table_reference['table'] + || $position !== $source_end + ) { + return null; + } + + $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 ( + ! $skip_where_validation + && ( + $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; + } + + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_name_end + ); + if ( null !== $table_reference['alias'] ) { + $table_reference_sql .= ' AS ' . $this->connection->quote_identifier( $table_reference['alias'] ); + } + + return array( + 'from_position' => $from_position, + 'limit_position' => $limit_position, + 'order_position' => $order_position, + 'select_end' => $select_end, + 'statement_end' => $statement_end, + 'table_alias' => $table_reference['alias'], + 'table_name' => $table_reference['table'], + 'table_reference_sql' => $table_reference_sql, + 'where_end' => $where_end, + 'where_position' => $where_position, + ); + } + private function build_simple_mysql_single_table_select_sql( + array $tokens, + array $context, + array $where_replacements = array(), + bool $include_wordpress_order_tiebreakers = true + ): string { + $sql = sprintf( + 'SELECT %s FROM %s', + $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $context['from_position'] ), + $context['table_reference_sql'] + ); + + $scope = $this->get_mysql_single_table_scope( $context['table_name'], $context['table_alias'] ); + if ( null !== $context['where_position'] ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $context['where_position'] + 1, + $context['where_end'], + $scope, + $where_replacements + ); + $sql .= ' WHERE ' . $where_sql['sql']; + } + + if ( null !== $context['order_position'] ) { + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $context['order_position'] + 2, + $context['select_end'], + $scope, + false + ); + $sql .= ' ORDER BY ' . $order_sql['sql']; + + if ( $include_wordpress_order_tiebreakers ) { + $tiebreakers = $this->get_simple_wordpress_select_order_tiebreaker_sql( $tokens, $context ); + if ( ! empty( $tiebreakers ) ) { + $sql .= ', ' . implode( ', ', $tiebreakers ); + } + } + } + + if ( null !== $context['limit_position'] ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( + $tokens, + $context['limit_position'], + $context['statement_end'] + ); + } + return $sql; + } + private function get_simple_wordpress_select_order_tiebreaker_sql( array $tokens, array $context ): array { + $tiebreakers = array(); + $scope = $this->get_mysql_single_table_scope( $context['table_name'], $context['table_alias'] ); + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $context['order_position'] + 2, + $context['select_end'], + array(), + $scope + ); + if ( null === $order_items ) { + return $tiebreakers; + } + + $tiebreaker_sql = $this->get_wordpress_posts_order_id_tiebreaker_sql( $tokens, $order_items, $scope, false ); + if ( null !== $tiebreaker_sql ) { + $tiebreakers[] = $tiebreaker_sql; + } + + $tiebreaker_sql = $this->get_simple_wordpress_approved_comments_order_tiebreaker_sql( + $tokens, + $context['table_name'], + $context['where_position'], + $context['where_end'], + $order_items + ); + if ( null !== $tiebreaker_sql ) { + $tiebreakers[] = $tiebreaker_sql; + } + return $tiebreakers; + } + private function translate_direct_information_schema_cte_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::RECURSIVE_SYMBOL === $tokens[ $position ]->id ) { + return null; + } + + $replacements = array(); + $cte_sources = array(); + while ( $position < $statement_end ) { + $cte = $this->parse_direct_information_schema_cte_select_definition( $query, $tokens, $position, $statement_end ); + if ( null === $cte ) { + return null; + } + + $name = $cte['name']; + $columns = $cte['columns']; + $replacements = array_merge( $replacements, $cte['replacements'] ); + $cte_sources[ strtolower( $name ) ] = compact( 'name', 'columns' ); + $position = $cte['position']; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( $this->select_references_direct_information_schema_relation( $tokens, $position + 1, $statement_end ) || ( 0 === strcasecmp( $this->db_name, 'information_schema' ) && $this->information_schema_cte_final_select_has_non_cte_table_reference( $tokens, $position, $statement_end, $cte_sources ) ) ) { + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $statement_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query, $cte_sources ); + if ( null === $translated_select ) { + return null; + } + + $replacements[] = $this->get_direct_information_schema_replacement( $position, $statement_end, $translated_select ); + } + + $this->sort_mysql_replacements( $replacements ); + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, 0, $statement_end, $replacements ); + } + private function parse_direct_information_schema_cte_select_definition( string $query, array $tokens, int $position, int $statement_end ): ?array { + $cte_name_position = $position; + $cte_name = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $cte_name ) { + return null; + } + + $replacements = array( $this->get_direct_information_schema_replacement( $cte_name_position, $cte_name_position + 1, $this->connection->quote_identifier( $cte_name ) ) ); + $column_list_insert_position = ++$position; + $columns = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $column_list = $this->parse_direct_information_schema_cte_column_list( $tokens, $position, $statement_end ); + if ( null === $column_list ) { + return null; + } + $columns = $column_list['columns']; + $position = $column_list['position']; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_select_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $statement_end ); + $select_start = $position + 2; + $select_end = null === $after_select_close ? null : $after_select_close - 1; + if ( null === $select_end || ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; + } + + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_or_nested_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + if ( null === $columns ) { + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $columns = $this->get_direct_information_schema_select_or_union_output_columns( $select_query, $select_tokens, $select_statement_end, true ); + if ( empty( $columns ) ) { + return null; + } + $replacements[] = $this->get_direct_information_schema_replacement( $column_list_insert_position, $column_list_insert_position, '(' . implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ) . ')' ); + } + + $name = $cte_name; + $position = $after_select_close; + $replacements[] = $this->get_direct_information_schema_replacement( $select_start, $select_end, $translated_select ); + return compact( 'name', 'columns', 'position', 'replacements' ); + } + private function get_direct_information_schema_replacement( int $start, int $end, string $sql ): array { + return compact( 'start', 'end', 'sql' ); + } + private function translate_direct_or_nested_information_schema_select_query( string $query ): ?string { + $translated_select = $this->translate_direct_information_schema_select_query( $query ); + return null === $translated_select ? $this->translate_application_select_with_direct_information_schema_nested_selects( $query ) : $translated_select; + } + private function parse_direct_information_schema_cte_column_list( array $tokens, int $position, int $statement_end ): ?array { + $after_column_list = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_column_list ) { + return null; + } + + $column_ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $after_column_list - 1 ); + if ( null === $column_ranges || array() === $column_ranges ) { + return null; + } + + $columns = array(); + $seen = array(); + foreach ( $column_ranges as $range ) { + if ( $range['start'] + 1 !== $range['end'] || ! isset( $tokens[ $range['start'] ] ) ) { + return null; + } + + $column = $this->get_direct_information_schema_identifier_token_value( $tokens[ $range['start'] ] ); + $column_key = null === $column ? null : strtolower( $column ); + if ( null === $column_key || isset( $seen[ $column_key ] ) ) { + return null; + } + + $seen[ $column_key ] = true; + $columns[] = $column; + } + $position = $after_column_list; + return compact( 'columns', 'position' ); + } + private function information_schema_cte_final_select_has_non_cte_table_reference( array $tokens, int $start, int $end, array $cte_sources ): bool { + $position = $start; + while ( $position < $end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return true; + } + + $segment_end = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UNION_SYMBOL, $position + 1, $end ) ?? $end; + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $position + 1, $segment_end ); + if ( null !== $from_position && $this->information_schema_cte_source_range_has_non_cte_table_reference( $tokens, $from_position + 1, $this->find_direct_information_schema_source_end( $tokens, $from_position + 1, $segment_end ), $cte_sources ) ) { + return true; + } + + if ( $segment_end >= $end ) { + return false; + } + + $position = $segment_end + 1; + if ( isset( $tokens[ $position ] ) && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::ALL_SYMBOL, WP_MySQL_Lexer::DISTINCT_SYMBOL ), true ) ) { + ++$position; + } + } + return false; + } + private function information_schema_cte_source_range_has_non_cte_table_reference( array $tokens, int $position, int $source_end, array $cte_sources ): bool { + while ( $position < $source_end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + + $identifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + ++$position; + continue; + } + + if ( ! isset( $cte_sources[ strtolower( $identifier ) ] ) ) { + return true; + } + + ++$position; + while ( $position < $source_end ) { + $token_id = $tokens[ $position ]->id ?? null; + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $token_id || WP_MySQL_Lexer::JOIN_SYMBOL === $token_id ) { + ++$position; + break; + } + + if ( in_array( $token_id, array( WP_MySQL_Lexer::INNER_SYMBOL, WP_MySQL_Lexer::CROSS_SYMBOL, WP_MySQL_Lexer::LEFT_SYMBOL, WP_MySQL_Lexer::RIGHT_SYMBOL ), true ) ) { + while ( $position < $source_end && WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + if ( $position < $source_end ) { + ++$position; + } + break; + } + + ++$position; + } + } + return false; + } + private function translate_direct_information_schema_select_query( string $query, array $cte_sources = array(), ?array &$query_context = null ): ?string { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( $this->contains_top_level_mysql_query_context_token( $query_context, 1, $statement_end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) ) { + return $this->translate_direct_information_schema_union_select_query( $query, $tokens, $statement_end ); + } + + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $context = $this->get_direct_information_schema_select_context( $query, $tokens, $statement_end, $cte_sources ); + if ( null === $context ) { + return $this->translate_direct_information_schema_no_from_select_query( $query, $tokens, $statement_end ); + } + + $nested_select_replacements = $this->get_direct_information_schema_range_replacements( + $query, + $tokens, + 1, + $statement_end, + null, + array( + 'nested_select_ranges' => array_merge( + array( + array( + 'start' => $projection_start, + 'end' => $context['from_position'], + ), + ), + $context['clause_ranges'] + ), + 'cover_nested_selects' => true, + 'coverage_replacements' => $context['source_replacements'], + ) + ); + if ( null === $nested_select_replacements ) { + return null; + } + + $replacements = $this->get_direct_information_schema_projection_replacements( $tokens, $projection_start, $context['from_position'], $context, $nested_select_replacements ); + if ( null === $replacements ) { + return null; + } + + $replacements = $this->get_direct_information_schema_range_replacements( + null, + $tokens, + 1, + $statement_end, + $context, + array( + 'replacements' => array_merge( $replacements, $context['join_predicate_replacements'], $nested_select_replacements ), + 'protected_ranges' => $nested_select_replacements, + 'expression_ranges' => array_merge( $context['join_predicate_ranges'], $context['clause_ranges'] ), + 'include_binary_operators' => true, + 'include_source_replacements' => true, + ) + ); + if ( null === $replacements ) { + return null; + } + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $replacements + ); + } + private function get_direct_information_schema_projection_replacements( array $tokens, int $start, int $end, array $context, array $protected_ranges ): ?array { + $descriptors = $this->get_direct_information_schema_projection_item_descriptors( $tokens, $start, $end, $context ); + if ( null === $descriptors ) { + return null; + } + + $replacements = array(); + foreach ( $descriptors as $descriptor ) { + if ( null !== $descriptor['replacement_sql'] ) { + $replacements[] = $this->get_direct_information_schema_replacement( $descriptor['expression_start'], $descriptor['expression_end'], $descriptor['replacement_sql'] ); + continue; + } + + $expression_replacements = $this->get_direct_information_schema_range_replacements( + null, + $tokens, + $descriptor['expression_start'], + $descriptor['expression_end'], + $context, + array( + 'protected_ranges' => $protected_ranges, + ) + ); + if ( null === $expression_replacements ) { + return null; + } + + $replacements = array_merge( $replacements, $expression_replacements ); + } + return $replacements; + } + private function get_direct_information_schema_projection_item_descriptors( array $tokens, int $start, int $end, array $context ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + $descriptors = array(); + foreach ( $ranges ?? array() as $range ) { + $descriptors[] = $this->get_direct_information_schema_projection_item_descriptor( $tokens, $range, $context ); + } + return null === $ranges || array() === $ranges || in_array( null, $descriptors, true ) ? null : $descriptors; + } + private function get_direct_information_schema_projection_item_descriptor( array $tokens, array $range, array $context ): ?array { + $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']; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $range['start'], $range['end'] ); + $alias = null === $as_position + ? $this->get_mysql_implicit_projection_alias( $tokens, $range['start'], $range['end'] ) + : $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + $star_sources = $this->get_direct_information_schema_star_projection_sources( + $tokens, + $expression_start, + $expression_end, + $context + ); + $count_bounds = $this->normalize_mysql_expression_bounds( $tokens, $expression_start, $expression_end ); + $count_star = $count_bounds['start'] + 4 === $count_bounds['end'] + && isset( $tokens[ $count_bounds['start'] ], $tokens[ $count_bounds['start'] + 1 ], $tokens[ $count_bounds['start'] + 2 ], $tokens[ $count_bounds['start'] + 3 ] ) + && $this->is_mysql_token_value( $tokens[ $count_bounds['start'] ], 'count' ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $count_bounds['start'] + 1 ]->id + && '*' === $tokens[ $count_bounds['start'] + 2 ]->get_bytes() + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $count_bounds['start'] + 3 ]->id; + $reference = $this->get_direct_information_schema_column_reference_for_expression( $tokens, $expression_start, $expression_end, $context ); + + $replacement_sql = null; + if ( null !== $star_sources ) { + $replacement_sql = $this->get_direct_information_schema_star_projection_sql( $star_sources ); + } elseif ( $range['start'] === $expression_start && $range['end'] === $expression_end && $count_star ) { + $replacement_sql = 'COUNT(*) AS ' . $this->connection->quote_identifier( 'COUNT(*)' ); + } elseif ( + null !== $reference + && 0 === strcasecmp( $reference['column'], 'TABLE_ROWS' ) + && isset( $reference['source']['view'] ) + && 'tables' === $reference['source']['view'] + && $this->direct_information_schema_select_groups_by_table_name_for_source( $tokens, $context, $reference['source'] ) + ) { + $replacement_sql = 'MAX(' . $reference['sql'] . ')'; + } + return compact( 'alias', 'count_star', 'expression_end', 'expression_start', 'range', 'reference', 'replacement_sql', 'star_sources' ); + } + private function direct_information_schema_select_groups_by_table_name_for_source( array $tokens, array $context, array $source ): bool { + foreach ( $context['clause_ranges'] as $range ) { + if ( + WP_MySQL_Lexer::GROUP_SYMBOL !== ( $tokens[ $range['start'] ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $range['start'] + 1 ]->id ?? null ) + ) { + continue; + } + + $group_items = $this->split_top_level_mysql_arguments( $tokens, $range['start'] + 2, $range['end'] ); + if ( null === $group_items ) { + return false; + } + + foreach ( $group_items as $group_item ) { + $reference = $this->get_direct_information_schema_column_reference_for_expression( $tokens, $group_item['start'], $group_item['end'], $context ); + $same_column_source = null !== $reference + && $reference['source']['source_start'] === $source['source_start'] + && $reference['source']['source_end'] === $source['source_end'] + && 0 === strcasecmp( $reference['source']['alias'], $source['alias'] ); + if ( + null !== $reference + && 0 === strcasecmp( $reference['column'], 'TABLE_NAME' ) + && $same_column_source + ) { + return true; + } + } + } + return false; + } + private function get_direct_information_schema_column_reference_for_expression( array $tokens, int $start, int $end, array $context ): ?array { + $reference = $this->get_direct_information_schema_column_reference_data( $tokens, $start, $end, $context ); + if ( null === $reference ) { + return null; + } + return array( + 'source' => $reference['source'], + 'column' => $reference['column'], + 'token_position' => $reference['token_position'], + 'sql' => $this->get_direct_information_schema_qualified_column_sql( $reference['source'], $reference['column'] ), + ); + } + private function get_direct_information_schema_column_reference_data( array $tokens, int $start, int $end, array $context ): ?array { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + $column = $this->get_direct_information_schema_unqualified_column_name( $tokens[ $start ], $context ); + $source = ! is_string( $column ) ? null : $this->get_direct_information_schema_unqualified_column_source( $column, $context ); + return null === $source ? null : array( + 'source' => $source, + 'column' => $column, + 'token_position' => $start, + ); + } + + $shape = null; + if ( $start + 3 === $end ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $start, $end ); + } elseif ( $start + 5 === $end ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $start, $end, true ); + } + return null === $shape ? null : $this->get_direct_information_schema_column_reference_data_for_shape( $shape, $context ); + } + private function get_direct_information_schema_unqualified_column_source( string $column, array $context ): ?array { + $source = null; + foreach ( $context['sources'] as $candidate_source ) { + if ( ! isset( $candidate_source['column_map'][ strtolower( $column ) ] ) ) { + continue; + } + if ( null !== $source ) { + return null; + } + $source = $candidate_source; + } + return $source; + } + private function get_direct_information_schema_column_reference_data_for_shape( array $shape, array $context ): ?array { + $source = $this->get_direct_information_schema_source_for_qualifier( $shape['qualifier'], $context ); + $column = null === $source ? null : ( $source['column_map'][ strtolower( $shape['member'] ) ] ?? null ); + return null === $source || null === $column + ? null + : array( + 'source' => $source, + 'column' => $column, + 'token_position' => $shape['member_position'], + 'end' => $shape['end'], + ); + } + private function get_direct_information_schema_dotted_reference_shape( array $tokens, int $position, int $end, bool $schema_prefixed = false, bool $allow_star = false ): ?array { + $qualifier_position = $schema_prefixed ? $position + 2 : $position; + $member_position = $schema_prefixed ? $position + 4 : $position + 2; + if ( + $member_position >= $end + || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) + || ( $schema_prefixed && WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $qualifier_position + 1 ]->id ?? null ) ) + ) { + return null; + } + + $first = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first || ( $schema_prefixed && 0 !== strcasecmp( $first, 'information_schema' ) ) ) { + return null; + } + + $qualifier = $schema_prefixed ? $this->get_direct_information_schema_identifier_token_value( $tokens[ $qualifier_position ] ?? null ) : $first; + $member = $this->get_direct_information_schema_identifier_token_value( $tokens[ $member_position ] ?? null ); + if ( null === $member && $allow_star && '*' === ( isset( $tokens[ $member_position ] ) ? $tokens[ $member_position ]->get_bytes() : null ) ) { + $member = '*'; + } + if ( null === $qualifier || null === $member ) { + return null; + } + + return array( + 'qualifier' => $qualifier, + 'member' => $member, + 'member_position' => $member_position, + 'end' => $member_position + 1, + ); + } + private function translate_direct_information_schema_no_from_select_query( string $query, array $tokens, int $statement_end ): ?string { + if ( + null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + 1, + $statement_end + ) + || ! $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, 0, $statement_end ) + ) { + return null; + } + + $sql = $this->translate_direct_information_schema_range_to_postgresql( + $query, + $tokens, + 1, + $statement_end, + null, + array( + 'nested_select_ranges' => array( + array( + 'start' => 1, + 'end' => $statement_end, + ), + ), + 'require_nested_selects' => true, + 'cover_nested_selects' => true, + ) + ); + return null === $sql ? null : 'SELECT ' . $sql; + } + private function get_direct_information_schema_range_replacements( ?string $query, array $tokens, int $start, int $end, ?array $context = null, array $options = array() ): ?array { + $replacements = $options['replacements'] ?? array(); + $protected_ranges = $options['protected_ranges'] ?? array(); + if ( isset( $options['nested_select_ranges'] ) ) { + $has_nested_select = $this->contains_mysql_token( $tokens, $start, $end, array( WP_MySQL_Lexer::SELECT_SYMBOL ) ); + if ( empty( $options['nested_selects_if_present'] ) || $has_nested_select ) { + if ( + null === $query + || ( + ! empty( $options['reject_nested_select_unions'] ) + && $this->contains_mysql_token( $tokens, $start, $end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) + ) + ) { + return null; + } + $nested_select_replacements = $this->get_information_schema_nested_select_replacements( + $query, + $tokens, + $options['nested_select_ranges'], + false + ); + if ( + null === $nested_select_replacements + || ( ! empty( $options['require_nested_selects'] ) && array() === $nested_select_replacements ) + ) { + return null; + } + if ( + ! empty( $options['cover_nested_selects'] ) + && ! $this->direct_information_schema_nested_selects_are_covered( + $tokens, + $options['coverage_start'] ?? $start, + $options['coverage_end'] ?? $end, + array_merge( $options['coverage_replacements'] ?? array(), $replacements, $nested_select_replacements ) + ) + ) { + return null; + } + $replacements = array_merge( $replacements, $nested_select_replacements ); + $protected_ranges = array_merge( $protected_ranges, $nested_select_replacements ); + } + } + + if ( null !== $context ) { + foreach ( + $options['expression_ranges'] ?? array( + array( + 'start' => $start, + 'end' => $end, + ), + ) as $range + ) { + $current_database_function_replacements = $this->get_direct_information_schema_current_database_function_replacements( + $tokens, + $range['start'], + $range['end'], + $protected_ranges + ); + if ( null === $current_database_function_replacements ) { + return null; + } + $column_replacements = $this->get_direct_information_schema_column_replacements( + $tokens, + $range['start'], + $range['end'], + $context, + array_merge( $protected_ranges, $current_database_function_replacements ) + ); + if ( null === $column_replacements ) { + return null; + } + $range_replacements = array_merge( $current_database_function_replacements, $column_replacements ); + if ( ! empty( $options['include_binary_operators'] ) ) { + $range_replacements = array_merge( + $range_replacements, + $this->get_direct_information_schema_binary_operator_replacements( + $tokens, + $range['start'], + $range['end'], + array_merge( $protected_ranges, $range_replacements ) + ) + ); + } + $replacements = array_merge( $replacements, $range_replacements ); + } + } + + if ( ! empty( $options['include_source_replacements'] ) ) { + if ( null === $context || ! isset( $context['source_replacements'] ) ) { + return null; + } + $replacements = array_merge( $replacements, $context['source_replacements'] ); + } + + $this->sort_mysql_replacements( $replacements ); + return $replacements; + } + private function translate_direct_information_schema_range_to_postgresql( ?string $query, array $tokens, int $start, int $end, ?array $context = null, array $options = array() ): ?string { + $replacements = $this->get_direct_information_schema_range_replacements( $query, $tokens, $start, $end, $context, $options ); + return null === $replacements ? null : $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $start, $end, $replacements ); + } + private function translate_application_select_with_direct_information_schema_nested_selects( string $query, ?array &$query_context = null ): ?string { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( ! $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, 0, $statement_end ) ) { + return null; + } + + $context = $this->get_simple_mysql_single_table_select_context( + $tokens, + $statement_end, + true, + true + ); + if ( null === $context || null === $context['where_position'] ) { + return null; + } + + $replacements = $this->get_direct_information_schema_range_replacements( + $query, + $tokens, + 1, + $statement_end, + null, + array( + 'nested_select_ranges' => array( + array( + 'start' => $context['where_position'] + 1, + 'end' => $context['where_end'], + ), + ), + 'require_nested_selects' => true, + 'cover_nested_selects' => true, + ) + ); + if ( + null === $replacements + || ! $this->is_supported_simple_mysql_expression_fragment_with_replacements( + $tokens, + $context['where_position'] + 1, + $context['where_end'], + $replacements + ) + ) { + return null; + } + + for ( $reference_position = 1; $reference_position + 2 < $statement_end; $reference_position++ ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $reference_position, $statement_end ); + if ( null === $shape || 0 !== strcasecmp( $shape['qualifier'], 'information_schema' ) ) { + continue; + } + + $replacement_end = $this->get_covering_mysql_replacement_range_end( $reference_position, $replacements ); + if ( null === $replacement_end || $reference_position + 2 >= $replacement_end ) { + return null; + } + } + return $this->build_simple_mysql_single_table_select_sql( $tokens, $context, $replacements, false ); + } + private function translate_direct_information_schema_union_select_query( string $query, array $tokens, int $statement_end ): ?string { + $union = $this->parse_direct_information_schema_union_select_segments( $query, $tokens, $statement_end, true ); + if ( null === $union ) { + return null; + } + + $segments = array(); + foreach ( $union['segments'] as $select_query ) { + $translated_select = $this->translate_direct_or_nested_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + $segments[] = $translated_select; + } + + $sql = $segments[0]; + foreach ( $union['operators'] as $index => $operator ) { + $sql .= ' ' . $operator . ' ' . $segments[ $index + 1 ]; + } + + if ( $union['tail_start'] < $statement_end ) { + $columns = $this->get_direct_information_schema_union_select_output_columns( $query, $tokens, $union['tail_start'] ); + if ( null === $columns || array() === $columns ) { + return null; + } + + $tail_sql = $this->translate_direct_information_schema_union_tail_to_postgresql( + $tokens, + $union['tail_start'], + $statement_end, + $columns + ); + if ( null === $tail_sql ) { + return null; + } + + $sql .= $tail_sql; + } + return $sql; + } + private function parse_direct_information_schema_union_select_segments( string $query, array $tokens, int $statement_end, bool $allow_tail = false ): ?array { + $segments = array(); + $operators = array(); + $position = 0; + $tail_start = $statement_end; + + while ( $position < $statement_end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $union_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UNION_SYMBOL, $position + 1, $statement_end ); + $select_end = $union_position ?? $statement_end; + if ( $allow_tail && null === $union_position ) { + foreach ( array( WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL ) as $token_id ) { + $tail_position = $this->find_top_level_mysql_token( $tokens, $token_id, $position + 1, $statement_end ); + if ( null !== $tail_position ) { + $select_end = min( $select_end, $tail_position ); + } + } + $tail_start = $select_end; + } + if ( $this->contains_top_level_mysql_token( $tokens, $position + 1, $select_end, array( WP_MySQL_Lexer::LIMIT_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL ) ) ) { + return null; + } + + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $select_end ); + if ( '' === $select_query ) { + return null; + } + $segments[] = $select_query; + if ( null === $union_position ) { + break; + } + + $operator_position = $union_position + 1; + if ( ! isset( $tokens[ $operator_position ] ) ) { + return null; + } + + $operators[] = WP_MySQL_Lexer::ALL_SYMBOL === $tokens[ $operator_position ]->id ? 'UNION ALL' : 'UNION'; + $position = in_array( $tokens[ $operator_position ]->id, array( WP_MySQL_Lexer::ALL_SYMBOL, WP_MySQL_Lexer::DISTINCT_SYMBOL ), true ) ? $operator_position + 1 : $operator_position; + } + return count( $segments ) < 2 || count( $operators ) + 1 !== count( $segments ) ? null : compact( 'segments', 'operators', 'tail_start' ); + } + private function translate_direct_information_schema_union_tail_to_postgresql( array $tokens, int $start, int $end, array $columns ): ?string { + $position = $start; + $sql = ''; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position + 1, $end ); + $order_end = $limit_position ?? $end; + $order_sql = $this->translate_direct_information_schema_union_order_by_clause_to_postgresql( + $tokens, + $position, + $order_end, + $columns + ); + if ( null === $order_sql ) { + return null; + } + + $sql .= $order_sql; + $position = $order_end; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIMIT_SYMBOL === $tokens[ $position ]->id ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $position, $end, true ); + if ( null === $limit_sql ) { + return null; + } + + $sql .= $limit_sql; + $position = $end; + } + return $position === $end && '' !== $sql ? $sql : null; + } + private function translate_direct_information_schema_union_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $columns ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + $items = array(); + foreach ( $ranges as $range ) { + $item_start = $range['start']; + $item_end = $range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( $item_start >= $item_end ) { + return null; + } + + if ( + $item_start + 1 === $item_end + && isset( $tokens[ $item_start ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $item_start ] ) + ) { + $ordinal = (int) $tokens[ $item_start ]->get_value(); + if ( $ordinal < 1 || $ordinal > count( $columns ) ) { + return null; + } + + $items[] = $tokens[ $item_start ]->get_bytes() . $direction; + continue; + } + + if ( $item_start + 1 !== $item_end || ! isset( $tokens[ $item_start ] ) ) { + return null; + } + + $column = $this->get_direct_information_schema_identifier_token_value( $tokens[ $item_start ] ); + if ( null === $column ) { + return null; + } + + $column_key = strtolower( $column ); + if ( ! isset( $column_lookup[ $column_key ] ) ) { + return null; + } + + $items[] = $this->connection->quote_identifier( $column_lookup[ $column_key ] ) . $direction; + } + return empty( $items ) ? null : ' ORDER BY ' . implode( ', ', $items ); + } + private function get_direct_information_schema_select_context( string $query, array $tokens, int $statement_end, array $cte_sources = array() ): ?array { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $source_start = $from_position + 1; + $source_end = $this->find_direct_information_schema_source_end( $tokens, $source_start, $statement_end ); + if ( $source_start >= $source_end ) { + return null; + } + + $sources = $this->parse_direct_information_schema_select_sources( $query, $tokens, $source_start, $source_end, $cte_sources ); + if ( null === $sources ) { + return null; + } + + $clause_ranges = $this->get_mysql_select_tail_clause_ranges( $tokens, $source_end, $statement_end ); + return array( + 'sources' => $sources['sources'], + 'from_position' => $from_position, + 'source_start' => $source_start, + 'source_end' => $source_end, + 'source_replacements' => $sources['source_replacements'], + 'join_predicate_ranges' => $sources['join_predicate_ranges'], + 'join_predicate_replacements' => $sources['join_predicate_replacements'], + 'using_columns' => $sources['using_columns'], + 'clause_ranges' => $clause_ranges, + ); + } + private function get_mysql_select_tail_clause_ranges( array $tokens, int $start, int $statement_end ): array { + $clause_ranges = array(); + $clause_starts = array(); + foreach ( self::MYSQL_DIRECT_INFORMATION_SCHEMA_SELECT_TAIL_CLAUSE_TOKENS as $token_id ) { + $position = $this->find_top_level_mysql_token( $tokens, $token_id, $start, $statement_end ); + if ( is_int( $position ) ) { + $clause_starts[] = $position; + } + } + sort( $clause_starts ); + foreach ( $clause_starts as $index => $range_start ) { + $clause_ranges[] = array( + 'start' => $range_start, + 'end' => $clause_starts[ $index + 1 ] ?? $statement_end, + ); + } + return $clause_ranges; + } + private function find_direct_information_schema_source_end( array $tokens, int $start, int $statement_end ): int { + return $this->find_first_top_level_mysql_token( $tokens, self::MYSQL_DIRECT_INFORMATION_SCHEMA_SOURCE_END_TOKENS, $start, $statement_end ) ?? $statement_end; + } + private function parse_direct_information_schema_select_sources( string $query, array $tokens, int $start, int $end, array $cte_sources = array() ): ?array { + if ( + 0 !== strcasecmp( $this->db_name, 'information_schema' ) + && ! $this->direct_information_schema_source_range_references_information_schema( $tokens, $start, $end ) + ) { + return null; + } + + $sources = array(); + $aliases = array(); + $source_replacements = array(); + $join_predicate_ranges = array(); + $join_predicate_replacements = array(); + $using_columns = array(); + $has_direct_source = false; + $has_non_joinable_relation = false; + $main_table_count = 0; + $position = $start; + + while ( $position < $end ) { + $source_start = $position; + $source = $this->parse_direct_information_schema_select_source( $query, $tokens, $position, $end, $cte_sources ); + $source = null === $source ? null : $this->get_direct_information_schema_select_source_descriptor( $source, $source_start, $aliases ); + if ( null === $source ) { + return null; + } + + $sources[] = $source; + $source_replacements[] = $source['replacement']; + $has_direct_source = $has_direct_source || 'direct_relation' === $source['family']; + $has_non_joinable_relation = $has_non_joinable_relation || ! empty( $source['non_joinable_relation'] ); + $main_table_count += 'main_table' === $source['family'] ? 1 : 0; + $position = $source['position']; + if ( count( $sources ) > 6 ) { + return null; + } + + $separator = $this->find_next_direct_information_schema_source_separator( $tokens, $position, $end ); + if ( null !== $separator && 'comma' === $separator['type'] ) { + if ( $position !== $separator['start'] ) { + return null; + } + + $position = $separator['source_start']; + continue; + } + + $predicate_end = null === $separator ? $end : $separator['start']; + $predicate = $this->get_direct_information_schema_join_predicate_range_data( + $tokens, + $position, + $predicate_end, + $sources, + $using_columns + ); + if ( null === $predicate ) { + return null; + } + if ( isset( $predicate['range'] ) ) { + $join_predicate_ranges[] = $predicate['range']; + } + if ( isset( $predicate['replacement'] ) ) { + $join_predicate_replacements[] = $predicate['replacement']; + } + if ( isset( $predicate['using_columns'] ) ) { + $using_columns = $this->merge_direct_information_schema_using_columns( $using_columns, $predicate['using_columns'] ); + } + + if ( null === $separator ) { + $position = $end; + break; + } + + $position = $separator['source_start']; + } + + if ( ! $has_direct_source || ( count( $sources ) > 1 && ( $has_non_joinable_relation || $main_table_count > 1 ) ) ) { + return null; + } + return compact( 'sources', 'source_replacements', 'join_predicate_ranges', 'join_predicate_replacements', 'using_columns' ); + } + private function get_direct_information_schema_select_source_descriptor( array $source, int $source_start, array &$aliases ): ?array { + if ( ! isset( $source['alias'], $source['position'] ) ) { + return null; + } + + $alias_key = strtolower( $source['alias'] ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + + $family = isset( $source['cte'] ) ? 'cte' : ( isset( $source['table'] ) ? 'main_table' : ( isset( $source['relation_sql'] ) || isset( $source['view'] ) ? 'direct_relation' : null ) ); + $columns = $source['columns'] ?? ( isset( $source['view'] ) ? $this->get_direct_information_schema_relation_columns( $source['view'] ) : null ); + if ( null === $family || null === $columns ) { + return null; + } + + if ( 'cte' === $family ) { + $replacement_sql = sprintf( '%s AS %s', $this->connection->quote_identifier( $source['cte'] ), $this->connection->quote_identifier( $source['alias'] ) ); + } elseif ( 'main_table' === $family ) { + $replacement_sql = $this->get_postgresql_dml_table_reference_sql( $source['table'], $source['alias'] ); + } else { + $source['relation_sql'] = $source['relation_sql'] ?? $this->get_direct_information_schema_relation_sql( $source['view'] ?? '' ); + if ( null === $source['relation_sql'] ) { + return null; + } + $replacement_sql = sprintf( '(%s) AS %s', $source['relation_sql'], $this->connection->quote_identifier( $source['alias'] ) ); + } + + $source['family'] = $family; + $source['columns'] = $columns; + $source['column_map'] = array(); + $source['source_start'] = $source_start; + $source['source_end'] = $source['position']; + $source['replacement'] = $this->get_direct_information_schema_replacement( $source['source_start'], $source['source_end'], $replacement_sql ); + $source['non_joinable_relation'] = isset( $source['view'] ) && false !== strpos( ' collation_character_set_applicability column_statistics columns_extensions files innodb_datafiles innodb_lock_waits innodb_tablespaces innodb_tablespaces_brief keywords optimizer_trace partitions profiling resource_groups schemata_extensions st_geometry_columns table_constraints_extensions tablespaces_extensions user_attributes view_routine_usage view_table_usage ', ' ' . strtolower( $source['view'] ) . ' ' ); + $aliases[ $alias_key ] = true; + foreach ( $columns as $column ) { + $source['column_map'][ strtolower( $column ) ] = $column; + } + return $source; + } + private function parse_direct_information_schema_select_source( string $query, array $tokens, int $position, int $end, array $cte_sources = array() ): ?array { + if ( isset( $tokens[ $position ], $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return $this->parse_direct_information_schema_derived_select_source( $query, $tokens, $position, $end ); + } + + $source_start = $position; + $first = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first ) { + return null; + } + + if ( isset( $cte_sources[ strtolower( $first ) ] ) ) { + $cte_source = $cte_sources[ strtolower( $first ) ]; + if ( ! isset( $cte_source['name'], $cte_source['columns'] ) || ! is_array( $cte_source['columns'] ) ) { + return null; + } + + $cte = (string) $cte_source['name']; + $columns = $cte_source['columns']; + ++$position; + return $this->get_direct_information_schema_aliased_select_source( compact( 'cte', 'columns' ), $tokens, $position, $end, $cte ); + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + if ( 0 !== strcasecmp( $first, 'information_schema' ) ) { + $is_main_schema = 0 === strcasecmp( $first, $this->main_db_name ) || 0 === strcasecmp( $first, 'public' ); + if ( $is_main_schema ) { + return $this->parse_direct_information_schema_main_table_source( $tokens, $source_start, $end ); + } + return null; + } + + $view = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $view ) { + return null; + } + $position += 2; + } else { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return $this->parse_direct_information_schema_main_table_source( $tokens, $source_start, $end ); + } + + $view = $first; + } + + $view = strtolower( $view ); + return $this->get_direct_information_schema_aliased_select_source( array( 'view' => $view ), $tokens, $position, $end, $view ); + } + private function parse_direct_information_schema_main_table_source( array $tokens, int $position, int $end ): ?array { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + $cannot_parse_main_table_source = null === $reference + || ( + 0 !== strcasecmp( $reference['schema'], $this->main_db_name ) + && 0 !== strcasecmp( $reference['schema'], 'public' ) + ); + if ( $cannot_parse_main_table_source ) { + return null; + } + + $columns = array(); + foreach ( $this->get_mysql_dml_column_metadata( $reference['table'] ) as $column ) { + if ( ! isset( $column['column_name'] ) ) { + return null; + } + $columns[] = (string) $column['column_name']; + } + + if ( empty( $columns ) ) { + return null; + } + $table = $reference['table']; + $alias = null === $reference['alias'] ? $table : $reference['alias']; + $position = $reference['position']; + return compact( 'table', 'alias', 'position', 'columns' ); + } + private function parse_direct_information_schema_derived_select_source( string $query, array $tokens, int $position, int $end ): ?array { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( + null === $after_close + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $columns = $this->get_direct_information_schema_select_or_union_output_columns( $select_query, $select_tokens, $select_statement_end ); + if ( null === $columns || array() === $columns ) { + return null; + } + + $position = $after_close; + $relation_sql = $translated_select; + return $this->get_direct_information_schema_aliased_select_source( compact( 'relation_sql', 'columns' ), $tokens, $position, $end, 'derived' ); + } + private function get_direct_information_schema_aliased_select_source( array $source, array $tokens, int &$position, int $end, string $default_alias ): ?array { + $alias = $this->parse_direct_information_schema_optional_alias( $tokens, $position, $end, $default_alias ); + if ( null === $alias ) { + return null; + } + $source['alias'] = $alias; + $source['position'] = $position; + return $source; + } + private function parse_direct_information_schema_optional_alias( array $tokens, int &$position, int $end, string $default_alias ): ?string { + $explicit = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $explicit = true; + ++$position; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $alias ) { + return $explicit ? null : $default_alias; + } + ++$position; + return $alias; + } + private function get_direct_information_schema_select_or_union_output_columns( string $query, array $tokens, int $statement_end, bool $preserve_token_names = false ): ?array { + if ( ! $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) ) { + $context = $this->get_direct_information_schema_select_context( $query, $tokens, $statement_end ); + return null === $context ? null : $this->get_direct_information_schema_projection_output_columns( $tokens, $context, $preserve_token_names ); + } + return $this->get_direct_information_schema_union_select_output_columns( $query, $tokens, $statement_end ); + } + private function get_direct_information_schema_union_select_output_columns( string $query, array $tokens, int $statement_end ): ?array { + $union = $this->parse_direct_information_schema_union_select_segments( $query, $tokens, $statement_end ); + if ( null === $union ) { + return null; + } + + $columns = null; + foreach ( $union['segments'] as $select_query ) { + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $context = $this->get_direct_information_schema_select_context( $select_query, $select_tokens, $select_statement_end ); + if ( null === $context ) { + return null; + } + + $branch_columns = $this->get_direct_information_schema_projection_output_columns( $select_tokens, $context ); + if ( null === $branch_columns || array() === $branch_columns ) { + return null; + } + + if ( null === $columns ) { + $columns = $branch_columns; + } elseif ( count( $columns ) !== count( $branch_columns ) ) { + return null; + } + } + return $columns; + } + private function get_direct_information_schema_projection_output_columns( array $tokens, array $context, bool $preserve_token_names = false ): ?array { + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $descriptors = $this->get_direct_information_schema_projection_item_descriptors( $tokens, $projection_start, $context['from_position'], $context ); + if ( null === $descriptors ) { + return null; + } + + $columns = array(); + foreach ( $descriptors as $descriptor ) { + if ( null !== $descriptor['star_sources'] ) { + if ( null !== $descriptor['alias'] ) { + return null; + } + $columns = array_merge( $columns, ...array_column( $descriptor['star_sources'], 'columns' ) ); + continue; + } + + $column = $descriptor['alias'] ?? ( $descriptor['count_star'] ? 'COUNT(*)' : null ); + if ( null === $column && null !== $descriptor['reference'] ) { + $column = $preserve_token_names ? $this->get_direct_information_schema_identifier_token_value( $tokens[ $descriptor['reference']['token_position'] ] ) : $descriptor['reference']['column']; + } + if ( null === $column ) { + return null; + } + $columns[] = $column; + } + return $columns; + } + private function get_direct_information_schema_star_projection_sql( array $sources ): string { + $select_lists = array(); + foreach ( $sources as $source ) { + $select = array(); + foreach ( $source['columns'] as $column ) { + $select[] = $this->connection->quote_identifier( $source['alias'] ) . '.' . $this->connection->quote_identifier( $column ) . ' AS ' . $this->connection->quote_identifier( $column ); + } + $select_lists[] = implode( ', ', $select ); + } + return implode( ', ', $select_lists ); + } + private function get_direct_information_schema_star_projection_sources( array $tokens, int $start, int $end, array $context ): ?array { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) && '*' === $tokens[ $start ]->get_bytes() ) { + return $context['sources']; + } + + $shape = null; + if ( $start + 3 === $end ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $start, $end, false, true ); + } elseif ( $start + 5 === $end ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $start, $end, true, true ); + } + if ( null === $shape || '*' !== $shape['member'] ) { + return null; + } + + $source = $this->get_direct_information_schema_source_for_qualifier( $shape['qualifier'], $context ); + return null === $source ? null : array( $source ); + } + private function find_next_direct_information_schema_source_separator( array $tokens, int $start, int $end ): ?array { + $depth = 0; + for ( $position = $start; $position < $end; $position++ ) { + $token_id = $tokens[ $position ]->id; + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token_id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token_id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $token_id ) { + return $this->get_direct_information_schema_source_separator( 'comma', $position, $position + 1 ); + } + + $source_start = null; + if ( WP_MySQL_Lexer::JOIN_SYMBOL === $token_id ) { + $source_start = $position + 1; + } elseif ( + isset( $tokens[ $position + 1 ] ) + && in_array( $token_id, array( WP_MySQL_Lexer::INNER_SYMBOL, WP_MySQL_Lexer::CROSS_SYMBOL ), true ) + && WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $source_start = $position + 2; + } elseif ( in_array( $token_id, array( WP_MySQL_Lexer::LEFT_SYMBOL, WP_MySQL_Lexer::RIGHT_SYMBOL ), true ) ) { + $join_position = $position + 1; + if ( isset( $tokens[ $join_position ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $join_position ]->id ) { + ++$join_position; + } + if ( isset( $tokens[ $join_position ] ) && WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $join_position ]->id ) { + $source_start = $join_position + 1; + } + } + + if ( null !== $source_start ) { + return $this->get_direct_information_schema_source_separator( 'join', $position, $source_start ); + } + } + return null; + } + private function get_direct_information_schema_source_separator( string $type, int $start, int $source_start ): array { + return compact( 'type', 'start', 'source_start' ); + } + private function get_direct_information_schema_join_predicate_range_data( array $tokens, int $start, int $end, array $sources, array $using_columns ): ?array { + if ( $start === $end ) { + return array(); + } + + $token_id = $tokens[ $start ]->id ?? null; + if ( in_array( $token_id, array( WP_MySQL_Lexer::USING_SYMBOL, WP_MySQL_Lexer::ON_SYMBOL ), true ) ) { + $using = $this->get_direct_information_schema_join_using_replacement( $tokens, $start, $end, $sources, $using_columns ); + if ( null !== $using ) { + return $using; + } + if ( WP_MySQL_Lexer::USING_SYMBOL === $token_id ) { + return null; + } + } + + if ( WP_MySQL_Lexer::ON_SYMBOL !== $token_id ) { + return null; + } + + if ( $this->contains_mysql_token( + $tokens, + $start + 1, + $end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) ) { + return null; + } + return array( + 'range' => array( + 'start' => $start, + 'end' => $end, + ), + ); + } + private function get_direct_information_schema_join_using_replacement( array $tokens, int $start, int $end, array $sources, array $using_columns ): ?array { + if ( count( $sources ) < 2 ) { + return null; + } + + $descriptor = $this->get_direct_information_schema_join_using_descriptor( $tokens, $start, $end, $sources ); + if ( null === $descriptor ) { + return null; + } + $current_source = $sources[ count( $sources ) - 1 ]; + $columns = array(); + $seen_columns = array(); + $new_using = array(); + + foreach ( $descriptor['columns'] as $column ) { + $column_key = strtolower( $column['column'] ); + if ( isset( $seen_columns[ $column_key ] ) ) { + return null; + } + + $aliases = $this->get_direct_information_schema_join_using_column_aliases( + $column_key, + $column['previous_sources'], + $current_source, + $using_columns, + $descriptor['guard_ambiguous_previous'] + ); + if ( null === $aliases ) { + return null; + } + + $seen_columns[ $column_key ] = true; + $new_using[ $column_key ] = array( + 'column' => $column['column'], + 'aliases' => $aliases, + ); + $columns[] = $this->connection->quote_identifier( $column['column'] ); + } + return $this->get_direct_information_schema_join_using_replacement_data( $start, $end, $columns, $new_using ); + } + private function get_direct_information_schema_join_using_descriptor( array $tokens, int $start, int $end, array $sources ): ?array { + $token_id = $tokens[ $start ]->id ?? null; + $current_source = $sources[ count( $sources ) - 1 ]; + $guard_ambiguous_previous = WP_MySQL_Lexer::USING_SYMBOL === $token_id; + + if ( $guard_ambiguous_previous ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) ) { + return null; + } + + $position = $start + 2; + $previous_sources = array_slice( $sources, 0, -1 ); + $columns = array(); + while ( $position < $end && isset( $tokens[ $position ] ) ) { + $current_column = $this->get_direct_information_schema_column_name_for_token( $tokens[ $position ], $current_source['column_map'] ); + if ( null === $current_column ) { + return null; + } + + $columns[] = array( + 'column' => $current_column, + 'previous_sources' => $previous_sources, + ); + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $position === $end ? compact( 'columns', 'guard_ambiguous_previous' ) : null; + } + return null; + } + return null; + } + + if ( WP_MySQL_Lexer::ON_SYMBOL !== $token_id ) { + return null; + } + + $current_alias = strtolower( $current_source['alias'] ); + $position = $start + 1; + $columns = array(); + while ( $position < $end ) { + $left = $this->parse_direct_information_schema_qualified_column_reference( $tokens, $position, $end, $sources ); + if ( null === $left || ! isset( $tokens[ $left['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $left['end'] ]->id ) { + return null; + } + + $right = $this->parse_direct_information_schema_qualified_column_reference( $tokens, $left['end'] + 1, $end, $sources ); + if ( null === $right || 0 !== strcasecmp( $left['column'], $right['column'] ) ) { + return null; + } + + $left_alias = strtolower( $left['source']['alias'] ); + $right_alias = strtolower( $right['source']['alias'] ); + if ( $left_alias === $current_alias && $right_alias !== $current_alias ) { + $previous_source = $right['source']; + $column = $left['column']; + } elseif ( $right_alias === $current_alias && $left_alias !== $current_alias ) { + $previous_source = $left['source']; + $column = $right['column']; + } else { + return null; + } + + $columns[] = array( + 'column' => $column, + 'previous_sources' => array( $previous_source ), + ); + $position = $right['end']; + + if ( $position === $end ) { + return compact( 'columns', 'guard_ambiguous_previous' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + return null; + } + private function get_direct_information_schema_join_using_column_aliases( string $column_key, array $previous_sources, array $current_source, array $using_columns, bool $guard_ambiguous_previous ): ?array { + $previous_matches = array(); + foreach ( $previous_sources as $source ) { + if ( isset( $source['column_map'][ $column_key ] ) ) { + $previous_matches[] = $source; + } + } + + if ( empty( $previous_matches ) ) { + return null; + } + + $matched_aliases = array(); + foreach ( $previous_matches as $source ) { + $matched_aliases[ strtolower( $source['alias'] ) ] = true; + } + + if ( $guard_ambiguous_previous && count( $previous_matches ) > 1 ) { + $merged_aliases = $using_columns[ $column_key ]['aliases'] ?? array(); + foreach ( $matched_aliases as $alias => $_ ) { + if ( ! isset( $merged_aliases[ $alias ] ) ) { + return null; + } + } + } + + $matched_aliases[ strtolower( $current_source['alias'] ) ] = true; + if ( isset( $using_columns[ $column_key ]['aliases'] ) ) { + $matched_aliases = array_merge( $using_columns[ $column_key ]['aliases'], $matched_aliases ); + } + + return $matched_aliases; + } + private function get_direct_information_schema_join_using_replacement_data( int $start, int $end, array $columns, array $using_columns ): ?array { + return empty( $columns ) ? null : array( + 'replacement' => $this->get_direct_information_schema_replacement( $start, $end, 'USING (' . implode( ', ', $columns ) . ')' ), + 'using_columns' => $using_columns, + ); + } + private function parse_direct_information_schema_qualified_column_reference( array $tokens, int $position, int $end, array $sources ): ?array { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $position, $end ); + return null === $shape ? null : $this->get_direct_information_schema_column_reference_data_for_shape( $shape, array( 'sources' => $sources ) ); + } + private function merge_direct_information_schema_using_columns( array $columns, array $new_columns ): array { + foreach ( $new_columns as $key => $metadata ) { + if ( isset( $columns[ $key ] ) ) { + $metadata['aliases'] = array_merge( $columns[ $key ]['aliases'], $metadata['aliases'] ); + } + $columns[ $key ] = $metadata; + } + return $columns; + } + private function direct_information_schema_source_range_references_information_schema( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position + 1 < $end; $position++ ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $position, $end ); + if ( null !== $shape && 0 === strcasecmp( $shape['qualifier'], 'information_schema' ) ) { + return true; + } + + $identifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $identifier && 0 === strcasecmp( $identifier, 'information_schema' ) && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + return true; + } + } + return false; + } + private function get_direct_information_schema_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( in_array( $token->id, self::MYSQL_DIRECT_INFORMATION_SCHEMA_IDENTIFIER_STOP_TOKENS, true ) ) { + return null; + } + + $value = $token->get_value(); + return 1 === preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $value ) ? $value : null; + } + private function get_direct_information_schema_relation_names(): array { + $names = array_keys( $this->get_direct_information_schema_relation_column_map() ); + sort( $names, SORT_STRING ); + return $names; + } + private function get_direct_information_schema_relation_columns( string $view ): ?array { + $columns = $this->get_direct_information_schema_relation_column_map(); + $view = strtolower( $view ); + return isset( $columns[ $view ] ) ? explode( ' ', $columns[ $view ] ) : null; + } + private function get_direct_information_schema_relation_column_map(): array { + static $columns = null; + if ( null !== $columns ) { + return $columns; + } + + $columns = array(); + $raw = <<<'RELATIONS' +schemata|CATALOG_NAME SCHEMA_NAME DEFAULT_CHARACTER_SET_NAME DEFAULT_COLLATION_NAME SQL_PATH DEFAULT_ENCRYPTION;tables|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME TABLE_TYPE ENGINE VERSION ROW_FORMAT TABLE_ROWS AVG_ROW_LENGTH DATA_LENGTH MAX_DATA_LENGTH INDEX_LENGTH DATA_FREE AUTO_INCREMENT CREATE_TIME UPDATE_TIME CHECK_TIME TABLE_COLLATION CHECKSUM CREATE_OPTIONS TABLE_COMMENT;columns|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME ORDINAL_POSITION COLUMN_DEFAULT IS_NULLABLE DATA_TYPE CHARACTER_MAXIMUM_LENGTH CHARACTER_OCTET_LENGTH NUMERIC_PRECISION NUMERIC_SCALE DATETIME_PRECISION CHARACTER_SET_NAME COLLATION_NAME COLUMN_TYPE COLUMN_KEY EXTRA PRIVILEGES COLUMN_COMMENT GENERATION_EXPRESSION SRS_ID;columns_extensions|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME ENGINE_ATTRIBUTE SECONDARY_ENGINE_ATTRIBUTE;statistics|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME NON_UNIQUE INDEX_SCHEMA INDEX_NAME SEQ_IN_INDEX COLUMN_NAME COLLATION CARDINALITY SUB_PART PACKED NULLABLE INDEX_TYPE COMMENT INDEX_COMMENT IS_VISIBLE EXPRESSION;column_statistics|SCHEMA_NAME TABLE_NAME COLUMN_NAME HISTOGRAM;table_constraints|CONSTRAINT_CATALOG CONSTRAINT_SCHEMA CONSTRAINT_NAME TABLE_SCHEMA TABLE_NAME CONSTRAINT_TYPE ENFORCED;table_constraints_extensions|CONSTRAINT_CATALOG CONSTRAINT_SCHEMA CONSTRAINT_NAME TABLE_SCHEMA TABLE_NAME ENGINE_ATTRIBUTE SECONDARY_ENGINE_ATTRIBUTE;key_column_usage|CONSTRAINT_CATALOG CONSTRAINT_SCHEMA CONSTRAINT_NAME TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME ORDINAL_POSITION POSITION_IN_UNIQUE_CONSTRAINT REFERENCED_TABLE_SCHEMA REFERENCED_TABLE_NAME REFERENCED_COLUMN_NAME;referential_constraints|CONSTRAINT_CATALOG CONSTRAINT_SCHEMA CONSTRAINT_NAME UNIQUE_CONSTRAINT_CATALOG UNIQUE_CONSTRAINT_SCHEMA UNIQUE_CONSTRAINT_NAME MATCH_OPTION UPDATE_RULE DELETE_RULE TABLE_NAME REFERENCED_TABLE_NAME;check_constraints|CONSTRAINT_CATALOG CONSTRAINT_SCHEMA CONSTRAINT_NAME CHECK_CLAUSE;character_sets|CHARACTER_SET_NAME DEFAULT_COLLATE_NAME DESCRIPTION MAXLEN;collations|COLLATION_NAME CHARACTER_SET_NAME ID IS_DEFAULT IS_COMPILED SORTLEN PAD_ATTRIBUTE;collation_character_set_applicability|COLLATION_NAME CHARACTER_SET_NAME;engines|ENGINE SUPPORT COMMENT TRANSACTIONS XA SAVEPOINTS;events|EVENT_CATALOG EVENT_SCHEMA EVENT_NAME DEFINER TIME_ZONE EVENT_BODY EVENT_DEFINITION EVENT_TYPE EXECUTE_AT INTERVAL_VALUE INTERVAL_FIELD SQL_MODE STARTS ENDS STATUS ON_COMPLETION CREATED LAST_ALTERED LAST_EXECUTED EVENT_COMMENT ORIGINATOR CHARACTER_SET_CLIENT COLLATION_CONNECTION DATABASE_COLLATION;files|FILE_ID FILE_NAME FILE_TYPE TABLESPACE_NAME TABLE_CATALOG TABLE_SCHEMA TABLE_NAME LOGFILE_GROUP_NAME LOGFILE_GROUP_NUMBER ENGINE FULLTEXT_KEYS DELETED_ROWS UPDATE_COUNT FREE_EXTENTS TOTAL_EXTENTS EXTENT_SIZE INITIAL_SIZE MAXIMUM_SIZE AUTOEXTEND_SIZE CREATION_TIME LAST_UPDATE_TIME LAST_ACCESS_TIME RECOVER_TIME TRANSACTION_COUNTER VERSION ROW_FORMAT TABLE_ROWS AVG_ROW_LENGTH DATA_LENGTH MAX_DATA_LENGTH INDEX_LENGTH DATA_FREE CREATE_TIME UPDATE_TIME CHECK_TIME CHECKSUM STATUS EXTRA;partitions|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME PARTITION_NAME SUBPARTITION_NAME PARTITION_ORDINAL_POSITION SUBPARTITION_ORDINAL_POSITION PARTITION_METHOD SUBPARTITION_METHOD PARTITION_EXPRESSION SUBPARTITION_EXPRESSION PARTITION_DESCRIPTION TABLE_ROWS AVG_ROW_LENGTH DATA_LENGTH MAX_DATA_LENGTH INDEX_LENGTH DATA_FREE CREATE_TIME UPDATE_TIME CHECK_TIME CHECKSUM PARTITION_COMMENT NODEGROUP TABLESPACE_NAME;tablespaces_extensions|TABLESPACE_NAME ENGINE_ATTRIBUTE;tablespaces|TABLESPACE_NAME ENGINE TABLESPACE_TYPE LOGFILE_GROUP_NAME EXTENT_SIZE AUTOEXTEND_SIZE MAXIMUM_SIZE NODEGROUP_ID TABLESPACE_COMMENT;innodb_tables|TABLE_ID NAME FLAG N_COLS SPACE ROW_FORMAT ZIP_PAGE_SIZE SPACE_TYPE INSTANT_COLS TOTAL_ROW_VERSIONS;innodb_tablespaces|SPACE NAME FLAG ROW_FORMAT PAGE_SIZE ZIP_PAGE_SIZE SPACE_TYPE FS_BLOCK_SIZE FILE_SIZE ALLOCATED_SIZE AUTOEXTEND_SIZE SERVER_VERSION SPACE_VERSION ENCRYPTION STATE;innodb_tablespaces_brief|SPACE NAME PATH FLAG SPACE_TYPE;innodb_datafiles|SPACE PATH;innodb_indexes|INDEX_ID NAME TABLE_ID TYPE N_FIELDS PAGE_NO SPACE MERGE_THRESHOLD;innodb_fields|INDEX_ID NAME POS;innodb_columns|TABLE_ID NAME POS MTYPE PRTYPE LEN HAS_DEFAULT DEFAULT_VALUE;innodb_lock_waits|REQUESTING_TRX_ID REQUESTED_LOCK_ID BLOCKING_TRX_ID BLOCKING_LOCK_ID +processlist|ID USER HOST DB COMMAND TIME STATE INFO;optimizer_trace|QUERY TRACE MISSING_BYTES_BEYOND_MAX_MEM_SIZE INSUFFICIENT_PRIVILEGES;profiling|QUERY_ID SEQ STATE DURATION CPU_USER CPU_SYSTEM CONTEXT_VOLUNTARY CONTEXT_INVOLUNTARY BLOCK_OPS_IN BLOCK_OPS_OUT MESSAGES_SENT MESSAGES_RECEIVED PAGE_FAULTS_MAJOR PAGE_FAULTS_MINOR SWAPS SOURCE_FUNCTION SOURCE_FILE SOURCE_LINE;keywords|WORD RESERVED;plugins|PLUGIN_NAME PLUGIN_VERSION PLUGIN_STATUS PLUGIN_TYPE PLUGIN_TYPE_VERSION PLUGIN_LIBRARY PLUGIN_LIBRARY_VERSION PLUGIN_AUTHOR PLUGIN_DESCRIPTION PLUGIN_LICENSE LOAD_OPTION;user_attributes|USER HOST ATTRIBUTE;user_privileges|GRANTEE TABLE_CATALOG PRIVILEGE_TYPE IS_GRANTABLE;schema_privileges|GRANTEE TABLE_CATALOG TABLE_SCHEMA PRIVILEGE_TYPE IS_GRANTABLE;table_privileges|GRANTEE TABLE_CATALOG TABLE_SCHEMA TABLE_NAME PRIVILEGE_TYPE IS_GRANTABLE;column_privileges|GRANTEE TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME PRIVILEGE_TYPE IS_GRANTABLE;resource_groups|RESOURCE_GROUP_NAME RESOURCE_GROUP_TYPE RESOURCE_GROUP_ENABLED VCPU_IDS THREAD_PRIORITY;applicable_roles|USER HOST GRANTEE GRANTEE_HOST ROLE_NAME ROLE_HOST IS_GRANTABLE IS_DEFAULT IS_MANDATORY;administrable_role_authorizations|USER HOST GRANTEE GRANTEE_HOST ROLE_NAME ROLE_HOST IS_GRANTABLE IS_DEFAULT IS_MANDATORY;enabled_roles|ROLE_NAME ROLE_HOST IS_DEFAULT IS_MANDATORY;role_table_grants|GRANTOR GRANTOR_HOST GRANTEE GRANTEE_HOST TABLE_CATALOG TABLE_SCHEMA TABLE_NAME PRIVILEGE_TYPE IS_GRANTABLE;role_column_grants|GRANTOR GRANTOR_HOST GRANTEE GRANTEE_HOST TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME PRIVILEGE_TYPE IS_GRANTABLE;role_routine_grants|GRANTOR GRANTOR_HOST GRANTEE GRANTEE_HOST SPECIFIC_CATALOG SPECIFIC_SCHEMA SPECIFIC_NAME ROUTINE_CATALOG ROUTINE_SCHEMA ROUTINE_NAME PRIVILEGE_TYPE IS_GRANTABLE;views|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME VIEW_DEFINITION CHECK_OPTION IS_UPDATABLE DEFINER SECURITY_TYPE CHARACTER_SET_CLIENT COLLATION_CONNECTION;schemata_extensions|CATALOG_NAME SCHEMA_NAME OPTIONS;view_table_usage|VIEW_CATALOG VIEW_SCHEMA VIEW_NAME TABLE_CATALOG TABLE_SCHEMA TABLE_NAME;view_routine_usage|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME SPECIFIC_CATALOG SPECIFIC_SCHEMA SPECIFIC_NAME;triggers|TRIGGER_CATALOG TRIGGER_SCHEMA TRIGGER_NAME EVENT_MANIPULATION EVENT_OBJECT_CATALOG EVENT_OBJECT_SCHEMA EVENT_OBJECT_TABLE ACTION_ORDER ACTION_CONDITION ACTION_STATEMENT ACTION_ORIENTATION ACTION_TIMING ACTION_REFERENCE_OLD_TABLE ACTION_REFERENCE_NEW_TABLE ACTION_REFERENCE_OLD_ROW ACTION_REFERENCE_NEW_ROW CREATED SQL_MODE DEFINER CHARACTER_SET_CLIENT COLLATION_CONNECTION DATABASE_COLLATION;st_geometry_columns|TABLE_CATALOG TABLE_SCHEMA TABLE_NAME COLUMN_NAME SRS_NAME SRS_ID GEOMETRY_TYPE_NAME;routines|SPECIFIC_NAME ROUTINE_CATALOG ROUTINE_SCHEMA ROUTINE_NAME ROUTINE_TYPE DATA_TYPE CHARACTER_MAXIMUM_LENGTH CHARACTER_OCTET_LENGTH NUMERIC_PRECISION NUMERIC_SCALE DATETIME_PRECISION CHARACTER_SET_NAME COLLATION_NAME DTD_IDENTIFIER ROUTINE_BODY ROUTINE_DEFINITION EXTERNAL_NAME EXTERNAL_LANGUAGE PARAMETER_STYLE IS_DETERMINISTIC SQL_DATA_ACCESS SQL_PATH SECURITY_TYPE CREATED LAST_ALTERED SQL_MODE ROUTINE_COMMENT DEFINER CHARACTER_SET_CLIENT COLLATION_CONNECTION DATABASE_COLLATION;parameters|SPECIFIC_CATALOG SPECIFIC_SCHEMA SPECIFIC_NAME ORDINAL_POSITION PARAMETER_MODE PARAMETER_NAME DATA_TYPE CHARACTER_MAXIMUM_LENGTH CHARACTER_OCTET_LENGTH NUMERIC_PRECISION NUMERIC_SCALE DATETIME_PRECISION CHARACTER_SET_NAME COLLATION_NAME DTD_IDENTIFIER ROUTINE_TYPE +RELATIONS; + foreach ( explode( ';', str_replace( "\n", ';', trim( $raw ) ) ) as $definition ) { + list( $relation, $relation_columns ) = explode( '|', $definition, 2 ); + $columns[ $relation ] = $relation_columns; + } + return $columns; + } + private function get_direct_information_schema_create_column_type( string $column ): string { + static $type_by_column = null; + + if ( null === $type_by_column ) { + $type_by_column = array(); + foreach ( array_combine( array( 'bigint DEFAULT NULL', 'decimal(20,6) DEFAULT NULL', 'longtext DEFAULT NULL', 'datetime DEFAULT NULL' ), array( 'VERSION TABLE_ID INDEX_ID FLAG N_COLS SPACE ZIP_PAGE_SIZE INSTANT_COLS TOTAL_ROW_VERSIONS PAGE_SIZE FS_BLOCK_SIZE FILE_SIZE ALLOCATED_SIZE SPACE_VERSION TYPE N_FIELDS PAGE_NO MERGE_THRESHOLD QUERY_ID SEQ MISSING_BYTES_BEYOND_MAX_MEM_SIZE CONTEXT_VOLUNTARY CONTEXT_INVOLUNTARY RESERVED RESOURCE_GROUP_ENABLED THREAD_PRIORITY BLOCK_OPS_IN BLOCK_OPS_OUT MESSAGES_SENT MESSAGES_RECEIVED PAGE_FAULTS_MAJOR PAGE_FAULTS_MINOR SWAPS SOURCE_LINE NODEGROUP NODEGROUP_ID POS MTYPE PRTYPE LEN HAS_DEFAULT TABLE_ROWS AVG_ROW_LENGTH DATA_LENGTH MAX_DATA_LENGTH INDEX_LENGTH DATA_FREE AUTO_INCREMENT ORDINAL_POSITION CHARACTER_MAXIMUM_LENGTH CHARACTER_OCTET_LENGTH NUMERIC_PRECISION NUMERIC_SCALE DATETIME_PRECISION SRS_ID NON_UNIQUE SEQ_IN_INDEX CARDINALITY SUB_PART POSITION_IN_UNIQUE_CONSTRAINT MAXLEN ID SORTLEN TIME ACTION_ORDER FILE_ID LOGFILE_GROUP_NUMBER FULLTEXT_KEYS DELETED_ROWS UPDATE_COUNT FREE_EXTENTS TOTAL_EXTENTS EXTENT_SIZE INITIAL_SIZE MAXIMUM_SIZE AUTOEXTEND_SIZE TRANSACTION_COUNTER ORIGINATOR PARTITION_ORDINAL_POSITION SUBPARTITION_ORDINAL_POSITION', 'DURATION CPU_USER CPU_SYSTEM', 'COLUMN_DEFAULT CHECK_CLAUSE GENERATION_EXPRESSION EXPRESSION VIEW_DEFINITION ACTION_CONDITION ACTION_STATEMENT ROUTINE_DEFINITION FILE_NAME EXTRA PARTITION_EXPRESSION SUBPARTITION_EXPRESSION PARTITION_DESCRIPTION ENGINE_ATTRIBUTE SECONDARY_ENGINE_ATTRIBUTE OPTIONS ATTRIBUTE VCPU_IDS SRS_NAME PATH DEFAULT_VALUE HISTOGRAM QUERY TRACE TABLESPACE_COMMENT', 'CREATE_TIME UPDATE_TIME CHECK_TIME CREATED EXECUTE_AT STARTS ENDS LAST_ALTERED LAST_EXECUTED CREATION_TIME LAST_UPDATE_TIME LAST_ACCESS_TIME RECOVER_TIME' ) ) as $type => $columns ) { + foreach ( explode( ' ', $columns ) as $typed_column ) { + $type_by_column[ $typed_column ] = $type; + } + } + } + return $type_by_column[ $column ] ?? 'varchar(512) DEFAULT NULL'; + } + private function get_direct_information_schema_static_literal_relation_sql( string $view ): ?string { + $descriptors = array( + array( + 'view' => 'character_sets', + 'show_result_type' => 'character_set', + 'projection' => array( + 'CHARACTER_SET_NAME' => 'Charset', + 'DEFAULT_COLLATE_NAME' => 'Default collation', + 'DESCRIPTION' => 'Description', + 'MAXLEN' => 'Maxlen', + ), + ), + array( + 'view' => 'collations', + 'show_result_type' => 'collation', + 'projection' => array( + 'COLLATION_NAME' => 'Collation', + 'CHARACTER_SET_NAME' => 'Charset', + 'ID' => 'Id', + 'IS_DEFAULT' => 'Default', + 'IS_COMPILED' => 'Compiled', + 'SORTLEN' => 'Sortlen', + 'PAD_ATTRIBUTE' => 'Pad_attribute', + ), + ), + array( + 'view' => 'collation_character_set_applicability', + 'show_result_type' => 'collation', + 'projection' => array( + 'COLLATION_NAME' => 'Collation', + 'CHARACTER_SET_NAME' => 'Charset', + ), + ), + array( + 'view' => 'engines', + 'show_result_type' => 'engines', + 'projection' => array( + 'ENGINE' => 'Engine', + 'SUPPORT' => 'Support', + 'COMMENT' => 'Comment', + 'TRANSACTIONS' => 'Transactions', + 'XA' => 'XA', + 'SAVEPOINTS' => 'Savepoints', + ), + ), + ); + foreach ( $descriptors as $descriptor ) { + if ( $view !== $descriptor['view'] ) { + continue; + } + $show_descriptor = $this->get_mysql_static_show_result_descriptor( $descriptor['show_result_type'], array(), null ); + return $this->get_direct_information_schema_literal_relation_sql( $this->get_direct_information_schema_relation_columns( $view ), $this->project_mysql_static_show_rows( $show_descriptor['rows'], $descriptor['projection'] ) ); + } + + return null; + } + private function get_direct_information_schema_relation_sql( string $view, array $options = array() ): ?string { + $view = strtolower( $view ); + if ( null === $this->get_direct_information_schema_relation_columns( $view ) ) { + return null; + } + + $literal_relation_sql = $this->get_direct_information_schema_static_literal_relation_sql( $view ); + if ( null !== $literal_relation_sql ) { + return $literal_relation_sql; + } + + if ( 'columns' === $view ) { + $comment_sql = 'pg_catalog.col_description(pc.oid, pa.attnum)'; + $type_expression = $this->get_direct_information_schema_catalog_data_type_expression( 'c', true, $comment_sql ); + $column_type = $this->get_direct_information_schema_catalog_column_type_expression( 'c', $this->get_postgresql_identity_sequence_comment_sql( 'c' ), $comment_sql ); + $charset = $this->get_direct_information_schema_character_set_expression( $column_type, 'NULL', $comment_sql, $this->connection->quote( self::DEFAULT_MYSQL_CHARSET ) ); + $collation = $this->get_direct_information_schema_collation_expression( $column_type, 'c.collation_name', $comment_sql, $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ) ); + $column_key = sprintf( '(SELECT CASE WHEN BOOL_OR(s."INDEX_NAME" = \'PRIMARY\') THEN \'PRI\' WHEN BOOL_OR(s."NON_UNIQUE" = 0) THEN \'UNI\' WHEN COUNT(*) > 0 THEN \'MUL\' ELSE \'\' END FROM (%1$s) s WHERE s."TABLE_SCHEMA" = %2$s AND s."TABLE_NAME" = c.table_name AND s."COLUMN_NAME" = c.column_name)', $this->get_direct_information_schema_relation_sql( 'statistics' ), $this->get_direct_information_schema_display_schema_sql( 'c.table_schema' ) ); + return $this->get_direct_information_schema_native_relation_sql( 'columns', $this->get_mysql_key_value_array( 'alias', 'c', 'from', 'information_schema.columns c', 'where', $this->get_postgresql_visible_schema_condition_sql( 'c.table_schema', 'pc.oid' ), 'expressions', 'COLUMN_DEFAULT=' . $this->get_direct_information_schema_column_default_expression( 'c', $comment_sql ) . '; DATA_TYPE=' . $type_expression . '; CHARACTER_OCTET_LENGTH=CASE WHEN c.character_maximum_length IS NULL THEN NULL ELSE c.character_maximum_length * 4 END; CHARACTER_SET_NAME=' . $charset . '; COLLATION_NAME=' . $collation . '; COLUMN_TYPE=' . $column_type . '; COLUMN_KEY=' . $column_key . '; EXTRA=' . $this->get_direct_information_schema_column_extra_expression( 'c', true, $comment_sql ) . '; PRIVILEGES=' . $this->connection->quote( 'select,insert,update,references' ) . '; COLUMN_COMMENT=' . $this->get_postgresql_catalog_comment_after_marker_lines_sql( $comment_sql, self::POSTGRESQL_CATALOG_COLUMN_COMMENT_MARKER_PREFIXES, count( self::POSTGRESQL_CATALOG_COLUMN_COMMENT_MARKER_PREFIXES ) ) . '; GENERATION_EXPRESSION=' . $this->connection->quote( '' ) . '; SRS_ID=NULL', 'join', 'LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = c.table_schema LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = c.table_name AND pc.relkind IN (\'r\', \'p\', \'v\', \'m\') LEFT JOIN pg_catalog.pg_attribute pa ON pa.attrelid = pc.oid AND pa.attname = c.column_name AND pa.attnum > 0' ) ); + } + if ( 'tables' === $view ) { + $postgresql_table_comment_sql = "pg_catalog.obj_description(pc.oid, 'pg_class')"; + $table_comment_prefix_sql = $this->connection->quote( self::MYSQL_TABLE_COMMENT_COLLATION_PREFIX ); + $table_comment_value_sql = sprintf( 'COALESCE(%s, \'\')', $postgresql_table_comment_sql ); + $table_comment_sql = $this->get_postgresql_catalog_comment_after_marker_lines_sql( $postgresql_table_comment_sql, array( self::MYSQL_TABLE_COMMENT_COLLATION_PREFIX ) ); + $table_collation_comment_sql = sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE NULL END', + $this->get_postgresql_catalog_base64_marker_condition_sql( $table_comment_value_sql, $table_comment_prefix_sql ), + $this->get_postgresql_catalog_base64_marker_decode_sql( $table_comment_value_sql, $table_comment_prefix_sql ) + ); + $table_collation_column_comment_sql = 'pg_catalog.col_description(table_collation_pc.oid, table_collation_pa.attnum)'; + $table_collation_column_type_sql = $this->get_direct_information_schema_catalog_column_type_expression( 'table_collation_columns', null, $table_collation_column_comment_sql ); + $table_collation_column_sql = $this->get_direct_information_schema_collation_expression( $table_collation_column_type_sql, 'table_collation_columns.collation_name', $table_collation_column_comment_sql, $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ) ); + $table_collation_sql = sprintf( 'COALESCE(%3$s, (SELECT %1$s FROM information_schema.columns table_collation_columns LEFT JOIN pg_catalog.pg_namespace table_collation_pn ON table_collation_pn.nspname = table_collation_columns.table_schema LEFT JOIN pg_catalog.pg_class table_collation_pc ON table_collation_pc.relnamespace = table_collation_pn.oid AND table_collation_pc.relname = table_collation_columns.table_name AND table_collation_pc.relkind IN (\'r\', \'p\', \'v\', \'m\') LEFT JOIN pg_catalog.pg_attribute table_collation_pa ON table_collation_pa.attrelid = table_collation_pc.oid AND table_collation_pa.attname = table_collation_columns.column_name AND table_collation_pa.attnum > 0 WHERE table_collation_columns.table_schema = t.table_schema AND table_collation_columns.table_name = t.table_name AND %1$s IS NOT NULL ORDER BY table_collation_columns.ordinal_position LIMIT 1), %2$s)', $table_collation_column_sql, $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ), $table_collation_comment_sql ); + return $this->get_direct_information_schema_native_relation_sql( 'tables', $this->get_mysql_key_value_array( 'alias', 't', 'from', 'information_schema.tables t', 'where', $this->get_postgresql_visible_schema_condition_sql( 't.table_schema', 'pc.oid' ) . ' AND t.table_type IN (\'BASE TABLE\', \'VIEW\')', 'expressions', 'TABLE_TYPE=CASE WHEN t.table_type = \'VIEW\' THEN \'VIEW\' ELSE \'BASE TABLE\' END; ENGINE=' . $this->connection->quote( 'InnoDB' ) . '; VERSION=10; ROW_FORMAT=' . $this->connection->quote( 'Dynamic' ) . '; TABLE_ROWS=GREATEST(CAST(COALESCE(pg_stat.n_live_tup, 0) AS bigint), CAST(COALESCE(pc.reltuples, 0) AS bigint), 0); AVG_ROW_LENGTH=0; DATA_LENGTH=0; MAX_DATA_LENGTH=0; INDEX_LENGTH=0; DATA_FREE=0; AUTO_INCREMENT=CASE WHEN identity_column.column_name IS NULL THEN NULL ELSE CAST(COALESCE(ps.last_value + ps.increment_by, ps.start_value, 1) AS bigint) END; CREATE_TIME=TO_CHAR(CURRENT_TIMESTAMP, \'YYYY-MM-DD HH24:MI:SS\'); UPDATE_TIME=NULL; CHECK_TIME=NULL; TABLE_COLLATION=' . $table_collation_sql . '; CHECKSUM=NULL; CREATE_OPTIONS=' . $this->connection->quote( '' ) . '; TABLE_COMMENT=' . $table_comment_sql, 'join', 'LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name AND pc.relkind IN (\'r\', \'p\', \'v\', \'m\') LEFT JOIN pg_catalog.pg_stat_all_tables pg_stat ON pg_stat.relid = pc.oid LEFT JOIN LATERAL (SELECT c.column_name, pg_catalog.pg_get_serial_sequence(pg_catalog.format(\'%I.%I\', t.table_schema, t.table_name), c.column_name)::regclass AS sequence_oid 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) identity_column ON TRUE LEFT JOIN pg_catalog.pg_class seq ON seq.oid = identity_column.sequence_oid LEFT JOIN pg_catalog.pg_namespace seq_ns ON seq_ns.oid = seq.relnamespace LEFT JOIN pg_catalog.pg_sequences ps ON ps.schemaname = seq_ns.nspname AND ps.sequencename = seq.relname' ) ); + } + + if ( in_array( $view, explode( ' ', 'events optimizer_trace profiling resource_groups user_attributes' ), true ) ) { + return $this->get_direct_information_schema_literal_relation_sql( $this->get_direct_information_schema_relation_columns( $view ), array() ); + } + + $native_relation_sql = $this->get_direct_information_schema_simple_native_relation_sql( $view ); + if ( null !== $native_relation_sql ) { + return $native_relation_sql; + } + if ( 'partitions' === $view ) { + $definitions = array( $this->get_mysql_key_value_array( 'alias', 'inh', 'from', 'pg_catalog.pg_inherits inh', 'where', 'parent_ns.nspname !~ ' . $this->connection->quote( '^(pg_|information_schema$|pg_catalog$)' ), 'expressions', 'TABLE_CATALOG=' . $this->connection->quote( 'def' ) . '; TABLE_SCHEMA=' . $this->get_direct_information_schema_display_schema_sql( 'parent_ns.nspname' ) . '; TABLE_NAME=parent_class.relname; PARTITION_NAME=child_class.relname; PARTITION_ORDINAL_POSITION=CAST(ROW_NUMBER() OVER (PARTITION BY parent_class.oid ORDER BY child_class.relname) AS bigint); PARTITION_METHOD=CASE WHEN partkey.definition LIKE \'RANGE%\' THEN \'RANGE\' WHEN partkey.definition LIKE \'LIST%\' THEN \'LIST\' WHEN partkey.definition LIKE \'HASH%\' THEN \'HASH\' ELSE NULL END; PARTITION_EXPRESSION=CASE WHEN partkey.definition IS NULL THEN NULL ELSE pg_catalog.regexp_replace(partkey.definition, \'^[^(]*\((.*)\)$\', \'\1\') END; PARTITION_DESCRIPTION=pg_catalog.pg_get_expr(child_class.relpartbound, child_class.oid); TABLE_ROWS=GREATEST(CAST(COALESCE(child_class.reltuples, 0) AS bigint), 0); AVG_ROW_LENGTH=0; DATA_LENGTH=0; MAX_DATA_LENGTH=0; INDEX_LENGTH=0; DATA_FREE=0; PARTITION_COMMENT=' . $this->connection->quote( '' ) . '; NODEGROUP=' . $this->connection->quote( 'default' ) . '; TABLESPACE_NAME=' . $this->connection->quote( 'DEFAULT' ), 'join', 'JOIN pg_catalog.pg_class child_class ON child_class.oid = inh.inhrelid JOIN pg_catalog.pg_class parent_class ON parent_class.oid = inh.inhparent JOIN pg_catalog.pg_namespace parent_ns ON parent_ns.oid = parent_class.relnamespace LEFT JOIN LATERAL (SELECT pg_catalog.pg_get_partkeydef(parent_class.oid) AS definition) partkey ON TRUE', 'default', 'NULL' ), $this->get_mysql_key_value_array( 'alias', 't', 'from', 'information_schema.tables t', 'where', 't.table_schema !~ ' . $this->connection->quote( '^(pg_|information_schema$|pg_catalog$)' ) . ' AND t.table_type = \'BASE TABLE\' AND (pc.oid IS NULL OR NOT EXISTS (SELECT 1 FROM pg_catalog.pg_inherits table_partition WHERE table_partition.inhrelid = pc.oid OR table_partition.inhparent = pc.oid))', 'expressions', 'TABLE_CATALOG=' . $this->connection->quote( 'def' ) . '; TABLE_SCHEMA=' . $this->get_direct_information_schema_display_schema_sql( 't.table_schema' ) . '; TABLE_NAME=t.table_name; TABLE_ROWS=GREATEST(CAST(COALESCE(pc.reltuples, 0) AS bigint), 0); AVG_ROW_LENGTH=0; DATA_LENGTH=0; MAX_DATA_LENGTH=0; INDEX_LENGTH=0; DATA_FREE=0; PARTITION_COMMENT=' . $this->connection->quote( '' ) . '; NODEGROUP=' . $this->connection->quote( '' ) . '; TABLESPACE_NAME=' . $this->connection->quote( 'DEFAULT' ), 'join', 'LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name AND pc.relkind IN (\'r\', \'p\')', 'default', 'NULL' ) ); + $sql = array(); + foreach ( $definitions as $definition ) { + $sql[] = $this->get_direct_information_schema_native_relation_sql( $view, $definition ); + } + return implode( "\nUNION ALL\n", $sql ); + } + + if ( 'st_geometry_columns' === $view ) { + $type_list = implode( ', ', array_map( array( $this->connection, 'quote' ), array_merge( array_keys( array_intersect( self::MYSQL_TEXT_DOMAIN_TYPES, self::MYSQL_SPATIAL_COLUMN_TYPES ) ), self::MYSQL_SPATIAL_COLUMN_TYPES ) ) ); + return $this->get_direct_information_schema_native_relation_sql( 'st_geometry_columns', $this->get_mysql_key_value_array( 'alias', 'c', 'from', 'information_schema.columns c', 'where', 'c.table_schema !~ ' . $this->connection->quote( '^(pg_|information_schema$|pg_catalog$)' ) . ' AND LOWER(COALESCE(c.domain_name, c.udt_name, c.data_type)) IN (' . $type_list . ')', 'expressions', 'SRS_NAME=NULL; SRS_ID=NULL; GEOMETRY_TYPE_NAME=UPPER(CASE WHEN c.domain_name LIKE ' . $this->connection->quote( '__wp_mysql_%' ) . ' THEN SUBSTR(c.domain_name, 12) ELSE COALESCE(c.udt_name, c.data_type) END)' ) ); + } + + if ( 'statistics' === $view ) { + $extra_projection = array(); + if ( ! empty( $options['include_internal_sort_column'] ) || ! empty( $options['include_show_create_table_sort_columns'] ) ) { + $extra_projection[] = 'postgresql_index_oid AS "POSTGRESQL_INDEX_OID"'; + } + if ( ! empty( $options['include_show_create_table_sort_columns'] ) ) { + $extra_projection[] = 'access_method AS "POSTGRESQL_ACCESS_METHOD"'; + } + + $column_name_sql = $this->get_postgresql_prefix_index_expression_column_name_sql( 'expression' ); + $index_type_sql = sprintf( 'COALESCE(%s, UPPER(access_method))', $this->get_postgresql_catalog_index_type_comment_sql( 'index_comment' ) ); + $sub_part_sql = $this->get_postgresql_catalog_display_index_sub_part_sql( 'expression', 'index_comment', 'seq_in_index' ); + return $this->get_direct_information_schema_native_relation_sql( 'statistics', $this->get_mysql_key_value_array( 'alias', 'index_columns', 'from', 'index_columns', 'expressions', 'TABLE_SCHEMA=' . $this->get_direct_information_schema_display_schema_sql( 'table_schema' ) . '; NON_UNIQUE=CASE WHEN indisunique THEN 0 ELSE 1 END; INDEX_SCHEMA=' . $this->get_direct_information_schema_display_schema_sql( 'table_schema' ) . '; INDEX_NAME=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; SEQ_IN_INDEX=CAST(seq_in_index AS integer); COLUMN_NAME=COALESCE(column_name, ' . $column_name_sql . '); COLLATION=CASE WHEN ' . $index_type_sql . ' = \'FULLTEXT\' THEN NULL ELSE CASE WHEN is_desc THEN \'D\' ELSE \'A\' END END; CARDINALITY=0; SUB_PART=CASE WHEN ' . $index_type_sql . ' = \'FULLTEXT\' THEN NULL ELSE ' . $sub_part_sql . ' END; PACKED=NULL; NULLABLE=CASE WHEN 0 = attnum OR attnotnull THEN \'\' ELSE \'YES\' END; INDEX_TYPE=' . $index_type_sql . '; COMMENT=' . $this->connection->quote( '' ) . '; INDEX_COMMENT=' . $this->get_postgresql_catalog_index_comment_sql( 'index_comment' ) . '; IS_VISIBLE=' . $this->connection->quote( 'YES' ) . '; EXPRESSION=' . $this->get_postgresql_non_prefix_index_expression_sql( 'expression' ), 'with', $this->get_postgresql_catalog_index_columns_cte_sql( 'n.nspname AS table_schema', '', array( $this->get_postgresql_visible_schema_condition_sql( 'n.nspname', 't.oid' ), 't.relkind IN (\'r\', \'p\')' ) ), 'extra_projection', $extra_projection ) ); + } + return null; + } + + private function get_postgresql_visible_schema_condition_sql( string $schema_sql, ?string $relation_oid_sql = null ): string { + $schema_condition = sprintf( '%1$s !~ %2$s', $schema_sql, $this->connection->quote( '^(pg_|information_schema$|pg_catalog$)' ) ); + if ( null === $relation_oid_sql ) { + return $schema_condition; + } + + return sprintf( '(%1$s OR (%2$s ~ %3$s AND %4$s IS NOT NULL AND pg_catalog.pg_table_is_visible(%4$s)))', $schema_condition, $schema_sql, $this->connection->quote( '^pg_temp_[0-9]+$' ), $relation_oid_sql ); + } + + private function get_direct_information_schema_simple_native_relation_sql( string $view ): ?string { + $check_comment_sql = 'pg_catalog.obj_description(con.oid, \'pg_constraint\')'; + $waiting_lock_id_sql = 'pg_catalog.concat_ws(\':\', waiting.locktype, waiting.mode, CAST(waiting.database AS text), CAST(waiting.relation AS text), CAST(waiting.page AS text), CAST(waiting.tuple AS text), CAST(waiting.virtualxid AS text), CAST(waiting.transactionid AS text), CAST(waiting.classid AS text), CAST(waiting.objid AS text), CAST(waiting.objsubid AS text))'; + $replacements = array_combine( + explode( ' ', '{{acl_grantee}} {{acl_role_join}} {{charset}} {{check_clause}} {{check_enforced}} {{collation}} {{concat}} {{datetime_format}} {{definer}} {{display_n_schema}} {{display_ref_table_schema}} {{display_stats_schema}} {{dynamic}} {{empty}} {{extension}} {{function}} {{general}} {{information_schema}} {{innodb}} {{no}} {{none}} {{normal}} {{n_normal}} {{pg_schema_filter}} {{schema_filter}} {{single}} {{tablespace_path}} {{user_host}} {{blocking_lock_id}} {{waiting_lock_id}}' ), + array( 'pg_catalog.quote_literal(CASE WHEN acl.grantee = 0 THEN \'PUBLIC\' ELSE grantee_role.rolname END) || ' . $this->connection->quote( '@\'%\'' ), 'LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee', $this->connection->quote( self::DEFAULT_MYSQL_CHARSET ), $this->get_postgresql_mysql_check_clause_comment_sql( $check_comment_sql, 'cc.check_clause' ), $this->get_postgresql_mysql_check_enforced_comment_sql( $check_comment_sql ), $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ), '||', $this->connection->quote( 'YYYY-MM-DD HH24:MI:SS' ), $this->connection->quote( 'DEFINER' ), $this->get_direct_information_schema_display_schema_sql( 'n.nspname' ), $this->get_direct_information_schema_display_schema_sql( 'ref_kcu.table_schema' ), $this->get_direct_information_schema_display_schema_sql( 'stats.schemaname' ), $this->connection->quote( 'Dynamic' ), $this->connection->quote( '' ), $this->connection->quote( 'EXTENSION' ), $this->connection->quote( 'FUNCTION' ), $this->connection->quote( 'General' ), $this->connection->quote( 'information_schema' ), $this->connection->quote( 'InnoDB' ), $this->connection->quote( 'NO' ), $this->connection->quote( 'NONE' ), $this->connection->quote( 'NORMAL' ), $this->connection->quote( 'normal' ), '!~ ' . $this->connection->quote( '^pg_' ), '!~ ' . $this->connection->quote( '^(pg_|information_schema$|pg_catalog$)' ), $this->connection->quote( 'Single' ), 'NULLIF(pg_catalog.pg_tablespace_location(ts.oid), \'\')', $this->connection->quote( '@\'%\'' ), str_replace( 'waiting.', 'blocking.', $waiting_lock_id_sql ), $waiting_lock_id_sql ) + ); + $constraint_join = 'LEFT JOIN pg_catalog.pg_namespace table_ns ON table_ns.nspname = tc.table_schema LEFT JOIN pg_catalog.pg_class table_class ON table_class.relnamespace = table_ns.oid AND table_class.relname = tc.table_name AND table_class.relkind IN (\'r\', \'p\') LEFT JOIN pg_catalog.pg_constraint con ON con.conrelid = table_class.oid AND con.conname = tc.constraint_name AND con.contype = \'c\''; + $raw_relation_rows = <<<'RELATIONS' +column_statistics|stats|pg_catalog.pg_stats stats|stats.schemaname {{schema_filter}}|SCHEMA_NAME={{display_stats_schema}}; TABLE_NAME=stats.tablename; COLUMN_NAME=stats.attname; HISTOGRAM=CAST(pg_catalog.json_build_object('buckets', COALESCE(pg_catalog.to_json(stats.histogram_bounds), '[]'::json), 'null-values', stats.null_frac, 'last-updated', NULL) AS text)~keywords|k|pg_catalog.pg_get_keywords() k||WORD=UPPER(k.word); RESERVED=CASE WHEN k.catcode = 'R' THEN 1 ELSE 0 END~plugins|ae|pg_catalog.pg_available_extensions ae||PLUGIN_NAME=ae.name; PLUGIN_VERSION=COALESCE(ae.installed_version, ae.default_version, ''); PLUGIN_STATUS=CASE WHEN ae.installed_version IS NULL THEN 'DISABLED' ELSE 'ACTIVE' END; PLUGIN_TYPE={{extension}}; PLUGIN_TYPE_VERSION=COALESCE(ae.default_version, ''); PLUGIN_AUTHOR={{empty}}; PLUGIN_DESCRIPTION=COALESCE(ae.comment, ''); PLUGIN_LICENSE={{empty}}; LOAD_OPTION=CASE WHEN ae.installed_version IS NULL THEN 'OFF' ELSE 'ON' END||NULL~processlist|a|pg_catalog.pg_stat_activity a|a.datname IS NULL OR a.datname = current_database()|ID=a.pid; USER=COALESCE(a.usename, CURRENT_USER); HOST=CASE WHEN a.client_addr IS NULL THEN 'localhost' WHEN a.client_port IS NULL THEN CAST(a.client_addr AS text) ELSE CAST(a.client_addr AS text) {{concat}} ':' {{concat}} CAST(a.client_port AS text) END; DB=COALESCE(a.datname, ''); COMMAND=CASE WHEN a.state = 'idle' THEN 'Sleep' ELSE 'Query' END; TIME=GREATEST(CAST(FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - COALESCE(a.query_start, a.state_change, a.backend_start, CURRENT_TIMESTAMP)))) AS bigint), 0); STATE=COALESCE(a.wait_event_type {{concat}} CASE WHEN a.wait_event IS NULL THEN '' ELSE ':' {{concat}} a.wait_event END, a.state, ''); INFO=COALESCE(a.query, '')~schemata|s|information_schema.schemata s|s.schema_name = {{information_schema}} OR s.schema_name {{pg_schema_filter}}|DEFAULT_CHARACTER_SET_NAME={{charset}}; DEFAULT_COLLATION_NAME={{collation}}; SQL_PATH=NULL; DEFAULT_ENCRYPTION={{no}} +innodb_columns|c|pg_catalog.pg_class c|c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped AND n.nspname {{schema_filter}}|TABLE_ID=CAST(c.oid AS bigint); NAME=a.attname; POS=CAST(a.attnum - 1 AS bigint); MTYPE=CASE WHEN typ.typcategory = 'N' THEN 6 WHEN typ.typcategory = 'S' THEN 1 WHEN typ.typcategory = 'B' THEN 6 WHEN typ.typcategory = 'U' THEN 14 ELSE 12 END; PRTYPE=0; LEN=CAST(CASE WHEN a.atttypmod > 0 THEN GREATEST(a.atttypmod - 4, 0) WHEN typ.typlen > 0 THEN typ.typlen ELSE 0 END AS bigint); HAS_DEFAULT=CASE WHEN def.adbin IS NULL THEN 0 ELSE 1 END; DEFAULT_VALUE=CASE WHEN def.adbin IS NULL THEN NULL ELSE pg_catalog.pg_get_expr(def.adbin, def.adrelid) END|JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid JOIN pg_catalog.pg_type typ ON typ.oid = a.atttypid LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = a.attrelid AND def.adnum = a.attnum~innodb_fields|idx|pg_catalog.pg_index idx|table_class.relkind IN ('r', 'p') AND table_ns.nspname {{schema_filter}}|INDEX_ID=CAST(idx_class.oid AS bigint); NAME=att.attname; POS=CAST(key_positions.position AS bigint)|JOIN pg_catalog.pg_class idx_class ON idx_class.oid = idx.indexrelid JOIN pg_catalog.pg_class table_class ON table_class.oid = idx.indrelid JOIN pg_catalog.pg_namespace table_ns ON table_ns.oid = table_class.relnamespace JOIN pg_catalog.generate_series(0, idx.indnkeyatts - 1) AS key_positions(position) ON TRUE JOIN pg_catalog.pg_attribute att ON att.attrelid = table_class.oid AND att.attnum = idx.indkey[key_positions.position]~innodb_indexes|idx|pg_catalog.pg_index idx|table_class.relkind IN ('r', 'p') AND table_ns.nspname {{schema_filter}}|INDEX_ID=CAST(idx_class.oid AS bigint); NAME=CASE WHEN idx.indisprimary THEN 'PRIMARY' ELSE idx_class.relname END; TABLE_ID=CAST(table_class.oid AS bigint); TYPE=CASE WHEN idx.indisprimary THEN 3 WHEN idx.indisunique THEN 2 ELSE 0 END; N_FIELDS=CAST(idx.indnkeyatts AS bigint); PAGE_NO=0; SPACE=CAST(COALESCE(NULLIF(table_class.reltablespace, 0::oid), db.dattablespace, 0::oid) AS bigint); MERGE_THRESHOLD=50|JOIN pg_catalog.pg_class idx_class ON idx_class.oid = idx.indexrelid JOIN pg_catalog.pg_class table_class ON table_class.oid = idx.indrelid JOIN pg_catalog.pg_namespace table_ns ON table_ns.oid = table_class.relnamespace LEFT JOIN pg_catalog.pg_database db ON db.datname = current_database()~innodb_lock_waits|waiting|pg_catalog.pg_locks waiting|NOT waiting.granted|REQUESTING_TRX_ID=CAST(waiting.pid AS text); REQUESTED_LOCK_ID={{waiting_lock_id}}; BLOCKING_TRX_ID=CAST(blocking.pid AS text); BLOCKING_LOCK_ID={{blocking_lock_id}}|JOIN pg_catalog.pg_locks blocking ON blocking.pid = ANY(pg_catalog.pg_blocking_pids(waiting.pid)) AND blocking.granted AND waiting.locktype = blocking.locktype AND waiting.database IS NOT DISTINCT FROM blocking.database AND waiting.relation IS NOT DISTINCT FROM blocking.relation AND waiting.page IS NOT DISTINCT FROM blocking.page AND waiting.tuple IS NOT DISTINCT FROM blocking.tuple AND waiting.virtualxid IS NOT DISTINCT FROM blocking.virtualxid AND waiting.transactionid IS NOT DISTINCT FROM blocking.transactionid AND waiting.classid IS NOT DISTINCT FROM blocking.classid AND waiting.objid IS NOT DISTINCT FROM blocking.objid AND waiting.objsubid IS NOT DISTINCT FROM blocking.objsubid~innodb_tables|c|pg_catalog.pg_class c|c.relkind IN ('r', 'p') AND n.nspname {{schema_filter}}|TABLE_ID=CAST(c.oid AS bigint); NAME={{display_n_schema}} {{concat}} '/' {{concat}} c.relname; FLAG=0; N_COLS=CAST(COALESCE(column_counts.n_cols, 0) + 3 AS bigint); SPACE=CAST(COALESCE(NULLIF(c.reltablespace, 0::oid), db.dattablespace, 0::oid) AS bigint); ROW_FORMAT={{dynamic}}; ZIP_PAGE_SIZE=0; SPACE_TYPE={{single}}; INSTANT_COLS=NULL; TOTAL_ROW_VERSIONS=0|JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_catalog.pg_database db ON db.datname = current_database() LEFT JOIN LATERAL (SELECT COUNT(*) AS n_cols FROM pg_catalog.pg_attribute a WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) column_counts ON TRUE +table_constraints|tc|information_schema.table_constraints tc|tc.table_schema {{schema_filter}}|CONSTRAINT_NAME=CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'PRIMARY' ELSE tc.constraint_name END; ENFORCED=CASE WHEN tc.constraint_type = 'CHECK' THEN {{check_enforced}} ELSE 'YES' END|{{constraint_join}}~table_constraints_extensions|tc|information_schema.table_constraints tc|tc.table_schema {{schema_filter}}|CONSTRAINT_NAME=CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'PRIMARY' ELSE tc.constraint_name END~key_column_usage|kcu|information_schema.key_column_usage kcu|kcu.table_schema {{schema_filter}}|CONSTRAINT_NAME=CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'PRIMARY' ELSE kcu.constraint_name END; REFERENCED_TABLE_SCHEMA={{display_ref_table_schema}}; REFERENCED_TABLE_NAME=ref_kcu.table_name; REFERENCED_COLUMN_NAME=ref_kcu.column_name|LEFT JOIN information_schema.table_constraints tc ON tc.constraint_schema = kcu.constraint_schema AND tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name LEFT JOIN information_schema.referential_constraints rc ON rc.constraint_schema = kcu.constraint_schema AND rc.constraint_name = kcu.constraint_name LEFT JOIN information_schema.key_column_usage ref_kcu ON ref_kcu.constraint_schema = rc.unique_constraint_schema AND ref_kcu.constraint_name = rc.unique_constraint_name AND ref_kcu.ordinal_position = kcu.position_in_unique_constraint~referential_constraints|rc|information_schema.referential_constraints rc|tc.table_schema {{schema_filter}}|UNIQUE_CONSTRAINT_NAME=CASE WHEN ref_tc.constraint_type = 'PRIMARY KEY' THEN 'PRIMARY' ELSE rc.unique_constraint_name END; TABLE_NAME=tc.table_name; REFERENCED_TABLE_NAME=ref_tc.table_name|JOIN information_schema.table_constraints tc ON tc.constraint_schema = rc.constraint_schema AND tc.constraint_name = rc.constraint_name LEFT JOIN information_schema.table_constraints ref_tc ON ref_tc.constraint_schema = rc.unique_constraint_schema AND ref_tc.constraint_name = rc.unique_constraint_name~check_constraints|cc|information_schema.check_constraints cc|tc.table_schema {{schema_filter}}|CHECK_CLAUSE={{check_clause}}|JOIN information_schema.table_constraints tc ON tc.constraint_schema = cc.constraint_schema AND tc.constraint_name = cc.constraint_name AND tc.constraint_type = 'CHECK' {{constraint_join}}~columns_extensions|c|information_schema.columns c|c.table_schema {{schema_filter}} +files|ts|pg_catalog.pg_tablespace ts||FILE_ID=CAST(ts.oid AS bigint); FILE_NAME={{tablespace_path}}; FILE_TYPE={{tablespace}}; TABLESPACE_NAME=ts.spcname; TABLE_CATALOG={{empty}}; ENGINE={{innodb}}; STATUS={{normal}}||NULL~tablespaces_extensions|ts|pg_catalog.pg_tablespace ts||TABLESPACE_NAME=ts.spcname||NULL~tablespaces|ts|pg_catalog.pg_tablespace ts||TABLESPACE_NAME=ts.spcname; ENGINE={{innodb}}; TABLESPACE_TYPE={{general}}; TABLESPACE_COMMENT=COALESCE(pg_catalog.obj_description(ts.oid, 'pg_tablespace'), '')||NULL~innodb_tablespaces|ts|pg_catalog.pg_tablespace ts||SPACE=CAST(ts.oid AS bigint); NAME=ts.spcname; FLAG=0; ROW_FORMAT={{dynamic}}; PAGE_SIZE=16384; ZIP_PAGE_SIZE=0; SPACE_TYPE={{single}}; AUTOEXTEND_SIZE=0; SPACE_VERSION=1; ENCRYPTION='N'; STATE={{n_normal}}||NULL~innodb_tablespaces_brief|ts|pg_catalog.pg_tablespace ts||SPACE=CAST(ts.oid AS bigint); NAME=ts.spcname; PATH={{tablespace_path}}; FLAG=0; SPACE_TYPE={{single}}||NULL~innodb_datafiles|ts|pg_catalog.pg_tablespace ts||SPACE=CAST(ts.oid AS bigint); PATH={{tablespace_path}}||NULL +user_privileges|acl|pg_catalog.pg_database d CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(d.datacl, pg_catalog.acldefault('d', d.datdba))) acl|d.datname = current_database()|GRANTEE={{acl_grantee}}; IS_GRANTABLE=CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END|{{acl_role_join}}~schema_privileges|acl|pg_catalog.pg_namespace n CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(n.nspacl, pg_catalog.acldefault('n', n.nspowner))) acl|n.nspname {{schema_filter}}|GRANTEE={{acl_grantee}}; TABLE_SCHEMA={{display_n_schema}}; IS_GRANTABLE=CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END|{{acl_role_join}}~table_privileges|tp|information_schema.table_privileges tp|tp.table_schema {{schema_filter}}|GRANTEE=pg_catalog.quote_literal(tp.grantee) {{concat}} {{user_host}}~column_privileges|cp|information_schema.column_privileges cp|cp.table_schema {{schema_filter}}|GRANTEE=pg_catalog.quote_literal(cp.grantee) {{concat}} {{user_host}}~role_table_grants|rtg|information_schema.role_table_grants rtg|rtg.table_schema {{schema_filter}}~role_column_grants|rcg|information_schema.role_column_grants rcg|rcg.table_schema {{schema_filter}}~role_routine_grants|rrg|information_schema.role_routine_grants rrg|rrg.specific_schema {{schema_filter}}~applicable_roles|ar|information_schema.applicable_roles ar||USER=ar.grantee; GRANTEE=ar.grantee~administrable_role_authorizations|ara|information_schema.administrable_role_authorizations ara||USER=ara.grantee; GRANTEE=ara.grantee~enabled_roles|er|information_schema.enabled_roles er~schemata_extensions|s|information_schema.schemata s|s.schema_name = {{information_schema}} OR s.schema_name {{pg_schema_filter}}~view_table_usage|vtu|information_schema.view_table_usage vtu|vtu.view_schema {{schema_filter}} AND vtu.table_schema {{schema_filter}}~view_routine_usage|vru|information_schema.view_routine_usage vru|vru.table_schema {{schema_filter}} AND vru.specific_schema {{schema_filter}}~views|v|information_schema.views v|v.table_schema {{schema_filter}}|CHECK_OPTION=COALESCE(v.check_option, {{none}}); IS_UPDATABLE=COALESCE(v.is_updatable, {{no}}); SECURITY_TYPE={{definer}}~triggers|t|information_schema.triggers t|t.trigger_schema {{schema_filter}}|CREATED=TO_CHAR(t.created, {{datetime_format}})~routines|r|information_schema.routines r|r.routine_schema {{schema_filter}}|CREATED=TO_CHAR(r.created, {{datetime_format}}); LAST_ALTERED=TO_CHAR(r.last_altered, {{datetime_format}})~parameters|p|information_schema.parameters p|p.specific_schema {{schema_filter}}|ROUTINE_TYPE=COALESCE(r.routine_type, {{function}})|LEFT JOIN information_schema.routines r ON r.specific_catalog = p.specific_catalog AND r.specific_schema = p.specific_schema AND r.specific_name = p.specific_name +RELATIONS; + $replacements['{{constraint_join}}'] = $constraint_join; + $replacements['{{tablespace}}'] = $this->connection->quote( 'TABLESPACE' ); + + foreach ( explode( '~', str_replace( "\n", '~', $raw_relation_rows ) ) as $row ) { + $fields = array_pad( explode( '|', $row, 7 ), 7, '' ); + if ( '' === $row || $view !== $fields[0] ) { + continue; + } + foreach ( $fields as $index => $field ) { + $fields[ $index ] = strtr( $field, $replacements ); + } + return $this->get_direct_information_schema_native_relation_sql( $view, $this->get_direct_information_schema_native_relation_definition( $fields[1], $fields[2], '' === $fields[3] ? null : $fields[3], '' === $fields[4] ? null : $fields[4], '' === $fields[5] ? null : $fields[5], '' === $fields[6] ? null : $fields[6] ) ); + } + return null; + } + private function get_direct_information_schema_native_relation_sql( string $view, array $definition ): string { + $sql = empty( $definition['with'] ) ? '' : 'WITH ' . $definition['with'] . "\n"; + $sql .= 'SELECT + ' . $this->get_direct_information_schema_ordered_native_projection_sql( $view, $definition ) . ' +FROM ' . $definition['from']; + return $sql + . ( empty( $definition['join'] ) ? '' : "\n" . $definition['join'] ) + . ( empty( $definition['where'] ) ? '' : "\nWHERE " . $definition['where'] ); + } + private function get_direct_information_schema_native_relation_definition( string $alias, string $from, ?string $where = null, $expressions = null, ?string $join = null, ?string $default_expression = null ): array { + return array_filter( + array_combine( array( 'alias', 'from', 'where', 'expressions', 'join', 'default' ), array( $alias, $from, $where, $expressions, $join, $default_expression ) ), + static function ( $value ): bool { + return null !== $value; + } + ); + } + private function get_direct_information_schema_ordered_native_projection_sql( string $view, array $definition ): string { + $columns = $this->get_direct_information_schema_relation_columns( $view ); + if ( null === $columns ) { + throw new LogicException( 'Unsupported direct information_schema relation projection.' ); + } + + $expressions = $this->get_direct_information_schema_native_projection_expression_map( $definition['expressions'] ?? array() ); + $projection = array(); + foreach ( $columns as $column ) { + $expression = array_key_exists( $column, $expressions ) ? $expressions[ $column ] : $this->get_direct_information_schema_default_native_projection_expression( $column, $definition ); + $projection[] = $expression . ' AS ' . $this->connection->quote_identifier( $column ); + } + + foreach ( array_filter( (array) ( $definition['extra_projection'] ?? array() ), 'strlen' ) as $extra_projection ) { + $projection[] = $extra_projection; + } + return implode( ",\n\t\t", $projection ); + } + private function get_direct_information_schema_native_projection_expression_map( $expressions ): array { + if ( is_array( $expressions ) ) { + return $expressions; + } + + $map = array(); + foreach ( explode( ';', $expressions ) as $entry ) { + $entry = trim( $entry ); + if ( '' === $entry ) { + continue; + } + + if ( false === strpos( $entry, '=' ) ) { + throw new LogicException( 'Malformed direct information_schema projection expression.' ); + } + + list( $column, $expression ) = explode( '=', $entry, 2 ); + $map[ trim( $column ) ] = trim( $expression ); + } + return $map; + } + private function get_direct_information_schema_default_native_projection_expression( string $column, array $definition ): string { + if ( array_key_exists( 'default', $definition ) ) { + return $definition['default']; + } + + $column_sql = $definition['alias'] . '.' . strtolower( $column ); + $default_column = '_CATALOG' === substr( $column, -8 ) ? 'CATALOG_NAME' : $column; + $default_projections = array_combine( array( 'CHARACTER_SET_CLIENT', 'COLLATION_CONNECTION', 'DATABASE_COLLATION', 'DEFINER', 'ROUTINE_COMMENT', 'GRANTOR_HOST', 'GRANTEE_HOST', 'HOST', 'ROLE_HOST', 'IS_DEFAULT', 'IS_MANDATORY', 'CATALOG_NAME', 'ENGINE_ATTRIBUTE', 'SECONDARY_ENGINE_ATTRIBUTE', 'OPTIONS' ), array( self::DEFAULT_MYSQL_CHARSET, self::DEFAULT_MYSQL_COLLATION, self::DEFAULT_MYSQL_COLLATION, '', '', '%', '%', '%', '%', 'NO', 'NO', 'def', null, null, null ) ); + if ( array_key_exists( $default_column, $default_projections ) ) { + return null === $default_projections[ $default_column ] ? 'NULL' : $this->connection->quote( $default_projections[ $default_column ] ); + } + if ( false !== strpos( $column, 'SCHEMA' ) ) { + return $this->get_direct_information_schema_display_schema_sql( $column_sql ); + } + return 'SQL_MODE' === $column ? $this->connection->quote( $this->get_sql_mode() ) : $column_sql; + } + private function get_direct_information_schema_current_database_function_replacements( array $tokens, int $start, int $end, array $protected_ranges = array() ): ?array { + $replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds || ! in_array( $bounds['function'], array( 'database', 'schema' ), true ) ) { + continue; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || array() !== $arguments ) { + return null; + } + + $replacements[] = $this->get_direct_information_schema_replacement( $position, $bounds['close'] + 1, $this->connection->quote( $this->db_name ) ); + $position = $bounds['close']; + } + return $replacements; + } + private function get_direct_information_schema_column_replacements( array $tokens, int $start, int $end, array $context, array $protected_ranges = array() ): ?array { + $replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + $qualified_reference = $this->get_direct_information_schema_qualified_column_replacement( $tokens, $position, $end, $context ); + if ( null !== $qualified_reference ) { + $replacements[] = $qualified_reference; + $position = $qualified_reference['end'] - 1; + continue; + } + + if ( + ( isset( $tokens[ $position - 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id ) + || ( isset( $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id ) + ) { + continue; + } + + $column_sql = $this->get_direct_information_schema_unqualified_column_sql( $tokens[ $position ], $context ); + if ( false === $column_sql ) { + return null; + } + if ( null === $column_sql ) { + continue; + } + + $replacements[] = $this->get_direct_information_schema_replacement( $position, $position + 1, $column_sql ); + } + return $replacements; + } + private function get_direct_information_schema_qualified_column_replacement( array $tokens, int $position, int $end, array $context ): ?array { + if ( $position + 4 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 3 ]->id ?? null ) ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $position, $end, true ); + if ( null === $shape ) { + return null; + } + } elseif ( WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position - 1 ]->id ?? null ) ) { + return null; + } else { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $position, $end ); + } + + $reference = null === $shape ? null : $this->get_direct_information_schema_column_reference_data_for_shape( $shape, $context ); + return null === $reference ? null : $this->get_direct_information_schema_replacement( $position, $reference['end'], $this->get_direct_information_schema_qualified_column_sql( $reference['source'], $reference['column'] ) ); + } + private function get_direct_information_schema_binary_operator_replacements( array $tokens, int $start, int $end, array $protected_ranges = array() ): array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + if ( + ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::BINARY_SYMBOL !== $tokens[ $position ]->id + || $position + 1 >= $end + || ! isset( $tokens[ $position + 1 ] ) + || in_array( $tokens[ $position + 1 ]->id, self::MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_INVALID_NEXT_TOKEN_IDS, true ) + ) { + continue; + } + + if ( $position > $start && isset( $tokens[ $position - 1 ] ) ) { + $previous_token_id = $tokens[ $position - 1 ]->id; + if ( + in_array( $previous_token_id, self::MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_INVALID_PREVIOUS_TOKEN_IDS, true ) + || ! in_array( $previous_token_id, self::MYSQL_DIRECT_INFORMATION_SCHEMA_BINARY_OPERATOR_VALID_PREVIOUS_TOKEN_IDS, true ) + ) { + continue; + } + } + + $replacements[] = $this->get_direct_information_schema_replacement( $position, $position + 1, '' ); + } + return $replacements; + } + private function get_covering_mysql_replacement_range_end( int $position, array $replacements ): ?int { + foreach ( $replacements as $replacement ) { + if ( $position >= $replacement['start'] && $position < $replacement['end'] ) { + return $replacement['end']; + } + } + return null; + } + private function sort_mysql_replacements( array &$replacements ): void { + usort( + $replacements, + static function ( array $left, array $right ): int { + return ( $left['start'] ?? 0 ) <=> ( $right['start'] ?? 0 ); + } + ); + } + private function get_information_schema_nested_select_replacements( string $query, array $tokens, array $ranges, bool $only_main_database_selects ): ?array { + $scan = $this->get_mysql_nested_select_replacement_scan( + $query, + $tokens, + $ranges, + array( + 'only_main_database_selects' => $only_main_database_selects, + ) + ); + return null === $scan ? null : $scan['replacements']; + } + private function direct_information_schema_nested_selects_are_covered( array $tokens, int $start, int $end, array $replacements ): bool { + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( null === $this->get_covering_mysql_replacement_range_end( $position, $replacements ) ) { + return false; + } + } + return true; + } + private function get_simple_mysql_dml_predicate_nested_select_replacements( string $query, array $tokens, int $start, int $end, array $cte_names = array() ): ?array { + $ranges = array( compact( 'start', 'end' ) ); + $scan = $this->get_mysql_nested_select_replacement_scan( + $query, + $tokens, + $ranges, + array( + 'cte_names' => $cte_names, + ) + ); + if ( null === $scan ) { + return null; + } + + if ( ! $scan['has_nested_select'] ) { + return array(); + } + + if ( $scan['has_direct_information_schema_select'] && $scan['has_cte_select'] ) { + return null; + } + + $replacements = $scan['replacements']; + if ( ! $this->direct_information_schema_nested_selects_are_covered( $tokens, $start, $end, $replacements ) ) { + return null; + } + + $this->sort_mysql_replacements( $replacements ); + return $replacements; + } + private function get_mysql_nested_select_replacement_scan( string $query, array $tokens, array $ranges, array $descriptor ): ?array { + $replacements = array(); + $has_nested_select = false; + $has_direct_information_schema_select = false; + $has_cte_select = false; + $scan_cte_sources = array_key_exists( 'cte_names', $descriptor ); + foreach ( $ranges as $range ) { + for ( $position = $range['start']; $position < $range['end']; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $range['end'] ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + if ( $scan_cte_sources ) { + $replacement_start = $position; + $replacement_end = $after_close; + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + $has_direct_information_schema_select = true; + $replacement_start = $select_start; + $replacement_end = $select_end; + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + } elseif ( $this->mysql_select_range_references_only_cte_sources( $tokens, $select_start, $select_end, $descriptor['cte_names'] ) ) { + $has_cte_select = true; + $translated_select = '(' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ) . ')'; + } else { + return null; + } + } else { + $replacement_start = $select_start; + $replacement_end = $select_end; + $translated_select = ! empty( $descriptor['only_main_database_selects'] ) + ? $this->translate_information_schema_main_database_select_query( $select_query ) + : $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + if ( + ! empty( $descriptor['only_main_database_selects'] ) + || $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) + ) { + return null; + } + + $translated_select = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ); + } + } + + if ( null === $translated_select ) { + return null; + } + + $has_nested_select = true; + $replacements[] = $this->get_direct_information_schema_replacement( $replacement_start, $replacement_end, $translated_select ); + $position = $after_close - 1; + } + } + return compact( 'has_cte_select', 'has_direct_information_schema_select', 'has_nested_select', 'replacements' ); + } + private function mysql_select_range_references_only_cte_sources( array $tokens, int $start, int $end, array $cte_names ): bool { + if ( empty( $cte_names ) || ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $start ]->id ) { + return false; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $start + 1, $end ); + if ( null === $from_position ) { + return false; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $end + ) ?? $end; + + $position = $from_position + 1; + $expect_source = true; + $source_count = 0; + while ( $position < $from_end ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return false; + } + + if ( $expect_source ) { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $from_end ); + if ( + null === $reference + || 'public' !== $reference['schema'] + || ! isset( $cte_names[ strtolower( $reference['table'] ) ] ) + ) { + return false; + } + + $position = $reference['position']; + $expect_source = false; + ++$source_count; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_source = true; + } + + ++$position; + } + return $source_count > 0 && ! $expect_source; + } + private function get_direct_information_schema_source_for_qualifier( string $qualifier, array $context ): ?array { + $matches = array(); + foreach ( $context['sources'] as $source ) { + if ( 0 === strcasecmp( $qualifier, $source['alias'] ) ) { + return $source; + } + + if ( isset( $source['view'] ) && 0 === strcasecmp( $qualifier, $source['view'] ) ) { + $matches[] = $source; + } + } + return 1 === count( $matches ) ? $matches[0] : null; + } + private function get_direct_information_schema_unqualified_column_sql( WP_MySQL_Token $token, array $context ) { + $column = $this->get_direct_information_schema_unqualified_column_name( $token, $context ); + if ( false === $column ) { + return false; + } + if ( null === $column ) { + return null; + } + + if ( isset( $context['using_columns'][ strtolower( $column ) ] ) ) { + return $this->connection->quote_identifier( $column ); + } + + if ( 1 === count( $context['sources'] ) ) { + return $this->connection->quote_identifier( $column ); + } + + foreach ( $context['sources'] as $source ) { + if ( isset( $source['column_map'][ strtolower( $column ) ] ) ) { + return $this->get_direct_information_schema_qualified_column_sql( $source, $column ); + } + } + return false; + } + private function get_direct_information_schema_unqualified_column_name( WP_MySQL_Token $token, array $context ) { + $value = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $value ) { + return null; + } + + $matches = array(); + $key = strtolower( $value ); + foreach ( $context['sources'] as $source ) { + if ( isset( $source['column_map'][ $key ] ) ) { + $matches[] = array( + 'source' => $source, + 'column' => $source['column_map'][ $key ], + ); + } + } + + if ( 0 === count( $matches ) ) { + return null; + } + + if ( count( $matches ) > 1 ) { + $using_column = $context['using_columns'][ $key ] ?? null; + if ( is_array( $using_column ) && isset( $using_column['column'], $using_column['aliases'] ) ) { + foreach ( $matches as $match ) { + if ( ! isset( $using_column['aliases'][ strtolower( $match['source']['alias'] ) ] ) ) { + return false; + } + } + return $using_column['column']; + } + return false; + } + return $matches[0]['column']; + } + private function get_direct_information_schema_qualified_column_sql( array $source, string $column ): string { + return $this->connection->quote_identifier( $source['alias'] ) . '.' . $this->connection->quote_identifier( $column ); + } + private function get_direct_information_schema_column_name_for_token( WP_MySQL_Token $token, array $column_map ): ?string { + $value = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $value ) { + return null; + } + return $column_map[ strtolower( $value ) ] ?? null; + } + private function get_direct_information_schema_literal_relation_sql( array $columns, array $rows ): string { + if ( empty( $rows ) ) { + $select = array(); + foreach ( $columns as $column ) { + $select[] = 'NULL AS ' . $this->connection->quote_identifier( $column ); + } + return 'SELECT ' . implode( ', ', $select ) . ' WHERE 1 = 0'; + } + + $selects = array(); + foreach ( $rows as $row ) { + $select = array(); + foreach ( $columns as $column ) { + $value = $row[ $column ] ?? null; + if ( null === $value ) { + $literal_sql = 'NULL'; + } elseif ( is_int( $value ) || is_float( $value ) || ( is_string( $value ) && 1 === preg_match( '/^-?[0-9]+(?:\.[0-9]+)?$/', $value ) ) ) { + $literal_sql = (string) $value; + } else { + $literal_sql = $this->connection->quote( (string) $value ); + } + $select[] = $literal_sql . ' AS ' . $this->connection->quote_identifier( $column ); + } + $selects[] = 'SELECT ' . implode( ', ', $select ); + } + return implode( ' UNION ALL ', $selects ); + } + private function get_postgresql_catalog_comment_after_marker_lines_sql( string $comment_sql, array $prefixes, int $passes = 1 ): string { + $stripped_comment = sprintf( 'COALESCE(%s, \'\')', $comment_sql ); + for ( $i = 0; $i < $passes; ++$i ) { + $marker_conditions = array(); + foreach ( $prefixes as $prefix ) { + $marker_conditions[] = $this->get_postgresql_catalog_marker_condition_sql( $stripped_comment, $prefix ); + } + + $stripped_comment = sprintf( + 'CASE + WHEN %2$s THEN + CASE + WHEN POSITION(CHR(10) IN %1$s) > 0 THEN SUBSTRING(%1$s FROM POSITION(CHR(10) IN %1$s) + 1) + ELSE \'\' + END + ELSE %1$s + END', + $stripped_comment, + implode( "\n\t\tOR ", $marker_conditions ) + ); + } + return $this->get_postgresql_catalog_user_comment_unescape_sql( $stripped_comment ); + } + private function get_direct_information_schema_display_schema_sql( string $schema_sql ): string { + return sprintf( + 'CASE WHEN %1$s = %2$s THEN %3$s ELSE %1$s END', + $schema_sql, + $this->connection->quote( 'public' ), + $this->connection->quote( $this->main_db_name ) + ); + } + private function get_direct_information_schema_display_schema( string $schema ): string { + return 0 === strcasecmp( $schema, 'public' ) ? $this->main_db_name : $schema; + } + private function get_direct_information_schema_catalog_data_type_expression( string $alias, bool $include_domain_cases = true, ?string $column_comment_sql = null ): string { + $comment_type_sql = null === $column_comment_sql + ? 'NULL' + : $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX ); + $column_comment_type_case = sprintf( + 'WHEN %1$s IS NOT NULL THEN %2$s + ', + $comment_type_sql, + $this->get_direct_information_schema_metadata_data_type_expression( $comment_type_sql, 'NULL' ) + ); + $enum_data_type_case = $include_domain_cases + ? sprintf( + 'WHEN %1$s.data_type = \'USER-DEFINED\' AND %1$s.udt_name LIKE \'__wp_mysql_enum_%%\' THEN \'enum\' + ', + $alias + ) + : ''; + $set_data_type_case = $include_domain_cases + ? sprintf( + 'WHEN %1$s.domain_name LIKE \'__wp_mysql_set_%%\' THEN \'set\' + ', + $alias + ) + : ''; + return sprintf( + 'CASE + %2$s%3$s%4$s%5$s + WHEN %1$s.data_type = \'character varying\' THEN \'varchar\' + WHEN %1$s.data_type = \'character\' THEN \'char\' + WHEN %1$s.data_type = \'integer\' THEN \'int\' + WHEN %1$s.data_type = \'numeric\' THEN \'decimal\' + WHEN %1$s.data_type = \'double precision\' THEN \'double\' + WHEN %1$s.data_type = \'real\' THEN \'float\' + WHEN %1$s.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE %1$s.data_type +END', + $alias, + $column_comment_type_case, + $enum_data_type_case, + $set_data_type_case, + $include_domain_cases ? $this->get_postgresql_mysql_domain_type_cases( $alias, false ) : '' + ); + } + private function get_direct_information_schema_catalog_column_type_expression( + string $catalog_alias, + ?string $identity_sequence_comment_sql = null, + ?string $column_comment_sql = null, + bool $include_helper_type_cases = true + ): string { + $identity_sequence_type_case = ''; + if ( null !== $identity_sequence_comment_sql ) { + $prefix = $this->connection->quote( self::MYSQL_IDENTITY_SEQUENCE_COMMENT_TYPE_PREFIX ); + $identity_sequence_type_case = sprintf( + 'WHEN %1$s LIKE %2$s || \'%%\' THEN SUBSTR(%1$s, LENGTH(%2$s) + 1) + ', + $identity_sequence_comment_sql, + $prefix + ); + } + $column_comment_type_case = ''; + if ( null !== $column_comment_sql ) { + $column_type_comment_sql = $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX ); + $column_comment_type_case = sprintf( + 'WHEN %1$s IS NOT NULL THEN %1$s + ', + $column_type_comment_sql + ); + } + $enum_column_type_case = $include_helper_type_cases + ? sprintf( + 'WHEN %1$s.data_type = \'USER-DEFINED\' AND %1$s.udt_name LIKE \'__wp_mysql_enum_%%\' THEN ( + SELECT \'enum(\' || pg_catalog.string_agg(pg_catalog.quote_literal(e.enumlabel), \',\' ORDER BY e.enumsortorder) || \')\' + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace ns + ON ns.oid = typ.typnamespace + INNER JOIN pg_catalog.pg_enum e + ON e.enumtypid = typ.oid + WHERE ns.nspname = %1$s.udt_schema + AND typ.typname = %1$s.udt_name + ) + ', + $catalog_alias + ) + : ''; + $set_column_type_case = $include_helper_type_cases + ? sprintf( + 'WHEN %1$s.domain_name LIKE \'__wp_mysql_set_%%\' THEN COALESCE((SELECT %2$s + FROM pg_catalog.pg_type domain_type + INNER JOIN pg_catalog.pg_namespace domain_ns + ON domain_ns.oid = domain_type.typnamespace + WHERE domain_ns.nspname = %1$s.domain_schema + AND domain_type.typname = %1$s.domain_name + ), \'set\') + ', + $catalog_alias, + $this->get_postgresql_catalog_column_comment_marker_sql( 'pg_catalog.obj_description(domain_type.oid, \'pg_type\')', self::MYSQL_COLUMN_COMMENT_TYPE_PREFIX ) + ) + : ''; + return sprintf( + 'CASE + %2$s%3$s%4$s%5$s%6$s + WHEN %1$s.data_type = \'character varying\' THEN + \'varchar\' || CASE WHEN %1$s.character_maximum_length IS NULL THEN \'\' ELSE \'(\' || CAST(%1$s.character_maximum_length AS text) || \')\' END + WHEN %1$s.data_type = \'character\' THEN + \'char\' || CASE WHEN %1$s.character_maximum_length IS NULL THEN \'\' ELSE \'(\' || CAST(%1$s.character_maximum_length AS text) || \')\' END + WHEN %1$s.data_type = \'integer\' THEN \'int\' + WHEN %1$s.data_type = \'numeric\' AND %1$s.numeric_precision IS NULL THEN \'numeric\' + WHEN %1$s.data_type = \'numeric\' THEN + \'decimal\' || CASE + WHEN %1$s.numeric_scale IS NULL THEN \'(\' || CAST(%1$s.numeric_precision AS text) || \')\' + ELSE \'(\' || CAST(%1$s.numeric_precision AS text) || \',\' || CAST(%1$s.numeric_scale AS text) || \')\' + END + WHEN %1$s.data_type = \'double precision\' THEN \'double\' + WHEN %1$s.data_type = \'real\' THEN \'float\' + WHEN %1$s.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE %1$s.data_type +END', + $catalog_alias, + $identity_sequence_type_case, + $column_comment_type_case, + $enum_column_type_case, + $set_column_type_case, + $include_helper_type_cases ? $this->get_postgresql_mysql_domain_type_cases( $catalog_alias, true ) : '' + ); + } + private function get_postgresql_identity_sequence_comment_sql( string $catalog_alias ): string { + return sprintf( + 'pg_catalog.obj_description(pg_catalog.pg_get_serial_sequence(format(\'%%I.%%I\', %1$s.table_schema, %1$s.table_name), %1$s.column_name)::regclass, \'pg_class\')', + $catalog_alias + ); + } + private function get_postgresql_mysql_domain_type_cases( string $catalog_alias, bool $include_type_length ): string { + $cases = array(); + foreach ( self::MYSQL_TEXT_DOMAIN_TYPES as $domain_name => $mysql_type ) { + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name, $include_type_length ? $mysql_type : $this->get_base_mysql_dml_column_type( $mysql_type ) ); + } + foreach ( self::MYSQL_BINARY_DOMAIN_TYPES as $domain_name => $mysql_type ) { + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name, $mysql_type ); + } + foreach ( array( 'binary', 'varbinary' ) as $type ) { + $domain_name = '__wp_mysql_' . $type; + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name, $type ); + $cases[] = sprintf( + $include_type_length ? 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s || \'(\' || SUBSTR(%1$s.domain_name, %4$d) || \')\'' : 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s', + $catalog_alias, + $this->connection->quote( $domain_name . '_%' ), + $this->connection->quote( $type ), + strlen( $domain_name ) + 2 + ); + } + foreach ( array_keys( self::MYSQL_INTEGER_DOMAIN_BASE_TYPES ) as $type ) { + $domain_name = '__wp_mysql_' . $type; + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name, $type ); + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name . '_unsigned', $include_type_length ? $type . ' unsigned' : $type ); + $cases[] = sprintf( + $include_type_length + ? 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s || \'(\' || SUBSTR(%1$s.domain_name, %4$d, LENGTH(%1$s.domain_name) - %5$d) || \') unsigned\'' + : 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s', + $catalog_alias, + $this->connection->quote( $domain_name . '_%_unsigned' ), + $this->connection->quote( $type ), + strlen( $domain_name ) + 2, + strlen( $domain_name ) + 10 + ); + $cases[] = sprintf( + $include_type_length + ? 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s || \'(\' || SUBSTR(%1$s.domain_name, %4$d) || \')\'' + : 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s', + $catalog_alias, + $this->connection->quote( $domain_name . '_%' ), + $this->connection->quote( $type ), + strlen( $domain_name ) + 2 + ); + } + foreach ( array( 'dec', 'fixed', 'float', 'double', 'real', 'numeric' ) as $type ) { + $domain_name = '__wp_mysql_' . $type; + $cases[] = $this->get_postgresql_mysql_domain_exact_type_case( $catalog_alias, $domain_name, $type ); + $cases[] = sprintf( + $include_type_length + ? 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s || \'(\' || REPLACE(SUBSTR(%1$s.domain_name, %4$d), \'_\', \',\') || \')\'' + : 'WHEN %1$s.domain_name LIKE %2$s THEN %3$s', + $catalog_alias, + $this->connection->quote( $domain_name . '_%' ), + $this->connection->quote( $type ), + strlen( $domain_name ) + 2 + ); + } + return implode( "\n\t", $cases ); + } + private function get_postgresql_mysql_domain_exact_type_case( string $catalog_alias, string $domain_name, string $mysql_type ): string { + return sprintf( + 'WHEN %s.domain_name = %s THEN %s', + $catalog_alias, + $this->connection->quote( $domain_name ), + $this->connection->quote( $mysql_type ) + ); + } + private function get_direct_information_schema_column_default_expression( string $catalog_alias, ?string $column_comment_sql = null ): string { + $fractional_timestamp_default_pattern = $this->connection->quote( "^\\s*left\\s*\\(\\s*to_char\\s*\\(\\s*\\(?\\s*current_timestamp\\(([0-6])\\)\\s+at\\s+time\\s+zone\\s+'UTC'(::text)?\\s*\\)?\\s*,\\s*'YYYY-MM-DD HH24:MI:SS\\.US'(::text)?\\s*\\)\\s*,\\s*2[1-6]\\s*\\)\\s*$" ); + $quoted_literal_default_pattern = $this->connection->quote( '^\'(.*)\'::((?:[a-z_][a-z0-9_]*\\.)?__wp_mysql_[a-z0-9_]+|character varying|character|text|bpchar|timestamp without time zone|timestamp with time zone|date|time without time zone|time with time zone|integer|bigint|smallint|numeric|decimal|double precision|real|boolean)$' ); + $null_default_pattern = $this->connection->quote( '^\\s*NULL(?:::[a-z_][a-z0-9_]*(?:[. ][a-z_][a-z0-9_]*)*(?:\\([0-9, ]+\\))?)?\\s*$' ); + $column_default_comment_sql = null === $column_comment_sql + ? 'NULL' + : $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX ); + return sprintf( + 'CASE + WHEN %1$s.is_identity = \'YES\' THEN NULL + WHEN LOWER(COALESCE(%1$s.column_default, \'\')) LIKE \'nextval(%%\' THEN NULL + WHEN %5$s IS NOT NULL THEN %5$s + WHEN %1$s.column_default ~* %6$s THEN NULL + WHEN %2$s THEN \'CURRENT_TIMESTAMP\' + WHEN %1$s.column_default ~* %3$s THEN \'CURRENT_TIMESTAMP(\' || SUBSTRING(%1$s.column_default FROM %3$s) || \')\' + WHEN %1$s.column_default ~ %4$s THEN REPLACE(SUBSTRING(%1$s.column_default FROM %4$s), CHR(39) || CHR(39), CHR(39)) + ELSE %1$s.column_default + END', + $catalog_alias, + $this->get_postgresql_catalog_current_timestamp_default_condition_sql( $catalog_alias, false ), + $fractional_timestamp_default_pattern, + $quoted_literal_default_pattern, + $column_default_comment_sql, + $null_default_pattern + ); + } + private function get_postgresql_catalog_marker_condition_sql( string $comment_sql, string $prefix ): string { + $prefix_sql = $this->connection->quote( $prefix ); + if ( self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX === $prefix ) { + return sprintf( + '%1$s ~ (\'^\' || %2$s || \'[0-9]+:[0-9]+($|\' || CHR(10) || \')\')', + $comment_sql, + $prefix_sql + ); + } + + return $this->get_postgresql_catalog_base64_marker_condition_sql( $comment_sql, $prefix_sql ); + } + private function get_postgresql_catalog_base64_marker_condition_sql( string $comment_sql, string $prefix_sql ): string { + $payload_sql = $this->get_postgresql_catalog_base64_marker_payload_sql( $comment_sql, $prefix_sql ); + return sprintf( + 'LEFT(%1$s, LENGTH(%2$s)) = %2$s + AND %3$s <> \'\' + AND %3$s ~ %4$s', + $comment_sql, + $prefix_sql, + $payload_sql, + $this->connection->quote( self::POSTGRESQL_CATALOG_BASE64_MARKER_PAYLOAD_SQL_PATTERN ) + ); + } + private function get_postgresql_catalog_base64_marker_payload_sql( string $comment_sql, string $prefix_sql ): string { + return sprintf( + 'split_part(SUBSTRING(%1$s FROM LENGTH(%2$s) + 1), CHR(10), 1)', + $comment_sql, + $prefix_sql + ); + } + private function get_postgresql_catalog_base64_marker_decode_sql( string $comment_sql, string $prefix_sql ): string { + return sprintf( + 'convert_from( + decode( + %1$s, + \'base64\' + ), + \'UTF8\' + )', + $this->get_postgresql_catalog_base64_marker_payload_sql( $comment_sql, $prefix_sql ) + ); + } + private function get_postgresql_catalog_user_comment_unescape_sql( string $comment_sql ): string { + $escaped_prefixes = array_merge( + array( self::POSTGRESQL_CATALOG_USER_COMMENT_ESCAPE_PREFIX ), + self::POSTGRESQL_CATALOG_COMMENT_MARKER_PREFIXES + ); + $prefix_pattern = implode( + '|', + array_map( + array( $this, 'get_postgresql_catalog_regex_literal' ), + $escaped_prefixes + ) + ); + return sprintf( + 'pg_catalog.regexp_replace( + %1$s, + %2$s || \'((\' || %3$s || \')[^\' || CHR(10) || \']*)\', + \'\1\2\', + \'g\' + )', + $comment_sql, + $this->connection->quote( '(^|' . "\n" . ')' . $this->get_postgresql_catalog_regex_literal( self::POSTGRESQL_CATALOG_USER_COMMENT_ESCAPE_PREFIX ) ), + $this->connection->quote( $prefix_pattern ) + ); + } + private function get_postgresql_catalog_regex_literal( string $literal ): string { + return strtr( + $literal, + array( + '\\' => '\\\\', + '.' => '\\.', + '^' => '\\^', + '$' => '\\$', + '*' => '\\*', + '+' => '\\+', + '?' => '\\?', + '(' => '\\(', + ')' => '\\)', + '[' => '\\[', + ']' => '\\]', + '{' => '\\{', + '}' => '\\}', + '|' => '\\|', + ) + ); + } + private function get_postgresql_catalog_column_comment_marker_sql( string $column_comment_sql, string $prefix ): string { + $prefix_sql = $this->connection->quote( $prefix ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $column_comment_sql ); + $cases = array(); + for ( $line_number = 1; $line_number <= count( self::POSTGRESQL_CATALOG_COLUMN_COMMENT_MARKER_PREFIXES ); ++$line_number ) { + $line_sql = sprintf( 'split_part(%s, CHR(10), %d)', $comment_sql, $line_number ); + $cases[] = sprintf( + 'WHEN %1$s THEN %2$s', + $this->get_postgresql_catalog_base64_marker_condition_sql( $line_sql, $prefix_sql ), + $this->get_postgresql_catalog_base64_marker_decode_sql( $line_sql, $prefix_sql ) + ); + } + return sprintf( + 'CASE + %1$s + ELSE NULL +END', + implode( "\n\t", $cases ) + ); + } + private function get_postgresql_catalog_column_comment_marker_decode_sql( string $comment_sql, string $prefix_sql ): string { + return $this->get_postgresql_catalog_base64_marker_decode_sql( $comment_sql, $prefix_sql ); + } + private function get_postgresql_catalog_index_comment_sql( string $index_comment_sql ): string { + $type_prefix_sql = $this->connection->quote( self::MYSQL_INDEX_COMMENT_TYPE_PREFIX ); + $sub_part_prefix_sql = $this->connection->quote( self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $index_comment_sql ); + $stripped_comment = sprintf( + 'CASE + WHEN %2$s THEN + CASE + WHEN POSITION(CHR(10) IN %1$s) > 0 THEN SUBSTRING(%1$s FROM POSITION(CHR(10) IN %1$s) + 1) + ELSE \'\' + END + WHEN %1$s ~ (\'^\' || %3$s || \'[0-9]+:[0-9]+($|\' || CHR(10) || \')\') THEN + pg_catalog.regexp_replace( + %1$s, + \'^(\' || %3$s || \'[0-9]+:[0-9]+\' || CHR(10) || \')*\' || %3$s || \'[0-9]+:[0-9]+(\' || CHR(10) || \')?\', + \'\' + ) + ELSE %1$s + END', + $comment_sql, + $this->get_postgresql_catalog_base64_marker_condition_sql( $comment_sql, $type_prefix_sql ), + $sub_part_prefix_sql + ); + return $this->get_postgresql_catalog_user_comment_unescape_sql( $stripped_comment ); + } + private function get_postgresql_catalog_index_type_comment_sql( string $index_comment_sql ): string { + $prefix_sql = $this->connection->quote( self::MYSQL_INDEX_COMMENT_TYPE_PREFIX ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $index_comment_sql ); + return sprintf( + 'CASE + WHEN %1$s THEN %2$s + ELSE NULL + END', + $this->get_postgresql_catalog_base64_marker_condition_sql( $comment_sql, $prefix_sql ), + $this->get_postgresql_catalog_base64_marker_decode_sql( $comment_sql, $prefix_sql ) + ); + } + private function get_postgresql_catalog_display_index_sub_part_sql( string $expression_sql, string $index_comment_sql, string $seq_in_index_sql ): string { + $prefix_sql = $this->connection->quote( self::MYSQL_INDEX_COMMENT_SUB_PART_PREFIX ); + $comment_sql = sprintf( 'COALESCE(%s, \'\')', $index_comment_sql ); + $expression_sub_part_sql = sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE SUBSTRING(%2$s FROM \', 1, ([0-9]+)[)]$\') END', + $this->get_postgresql_prefix_index_expression_column_name_sql( $expression_sql ), + $expression_sql + ); + return sprintf( + 'COALESCE(%1$s, (pg_catalog.regexp_match( + %2$s, + \'(^|\' || CHR(10) || \')\' || %3$s || CAST(%4$s AS text) || \':([0-9]+)($|\' || CHR(10) || \')\' +))[2])', + $expression_sub_part_sql, + $comment_sql, + $prefix_sql, + $seq_in_index_sql + ); + } + private function get_postgresql_catalog_current_timestamp_default_condition_sql( string $catalog_alias, bool $include_fractional = true ): string { + $current_timestamp_default_pattern = $this->connection->quote( "^\\s*to_char\\s*\\(\\s*\\(?\\s*current_timestamp(\\(\\))?\\s+at\\s+time\\s+zone\\s+'UTC'(::text)?\\s*\\)?\\s*,\\s*'YYYY-MM-DD HH24:MI:SS'(::text)?\\s*\\)\\s*$" ); + $fractional_timestamp_default_pattern = $this->connection->quote( "^\\s*left\\s*\\(\\s*to_char\\s*\\(\\s*\\(?\\s*current_timestamp\\(([0-6])\\)\\s+at\\s+time\\s+zone\\s+'UTC'(::text)?\\s*\\)?\\s*,\\s*'YYYY-MM-DD HH24:MI:SS\\.US'(::text)?\\s*\\)\\s*,\\s*2[1-6]\\s*\\)\\s*$" ); + $condition = sprintf( '%1$s.column_default ~* %2$s', $catalog_alias, $current_timestamp_default_pattern ); + + if ( $include_fractional ) { + $condition .= sprintf( ' OR %1$s.column_default ~* %2$s', $catalog_alias, $fractional_timestamp_default_pattern ); + } + return '(' . $condition . ')'; + } + private function get_direct_information_schema_metadata_data_type_expression( string $column_type_sql, string $fallback_sql ): string { + return sprintf( + 'CASE + WHEN %1$s IS NULL THEN %2$s + WHEN LOWER(%1$s) LIKE \'bigint%%\' THEN \'bigint\' + WHEN LOWER(%1$s) LIKE \'int1%%\' THEN \'int1\' + WHEN LOWER(%1$s) LIKE \'int2%%\' THEN \'int2\' + WHEN LOWER(%1$s) LIKE \'int3%%\' THEN \'int3\' + WHEN LOWER(%1$s) LIKE \'int4%%\' THEN \'int4\' + WHEN LOWER(%1$s) LIKE \'int8%%\' THEN \'int8\' + WHEN LOWER(%1$s) LIKE \'mediumint%%\' THEN \'mediumint\' + WHEN LOWER(%1$s) LIKE \'smallint%%\' THEN \'smallint\' + WHEN LOWER(%1$s) LIKE \'tinyint%%\' THEN \'tinyint\' + WHEN LOWER(%1$s) LIKE \'int%%\' THEN \'int\' + WHEN LOWER(%1$s) LIKE \'integer%%\' THEN \'int\' + WHEN LOWER(%1$s) LIKE \'varchar%%\' THEN \'varchar\' + WHEN LOWER(%1$s) LIKE \'char%%\' THEN \'char\' + WHEN LOWER(%1$s) LIKE \'varbinary%%\' THEN \'varbinary\' + WHEN LOWER(%1$s) LIKE \'binary%%\' THEN \'binary\' + WHEN LOWER(%1$s) LIKE \'decimal%%\' THEN \'decimal\' + WHEN LOWER(%1$s) LIKE \'numeric%%\' THEN \'decimal\' + WHEN LOWER(%1$s) LIKE \'datetime%%\' THEN \'datetime\' + WHEN LOWER(%1$s) LIKE \'timestamp%%\' THEN \'timestamp\' + WHEN LOWER(%1$s) LIKE \'enum%%\' THEN \'enum\' + WHEN LOWER(%1$s) LIKE \'set%%\' THEN \'set\' + WHEN LOWER(%1$s) LIKE \'double%%\' THEN \'double\' + WHEN LOWER(%1$s) LIKE \'float%%\' THEN \'float\' + WHEN LOWER(%1$s) LIKE \'longblob%%\' THEN \'longblob\' + WHEN LOWER(%1$s) LIKE \'mediumblob%%\' THEN \'mediumblob\' + WHEN LOWER(%1$s) LIKE \'tinyblob%%\' THEN \'tinyblob\' + WHEN LOWER(%1$s) LIKE \'blob%%\' THEN \'blob\' + WHEN LOWER(%1$s) LIKE \'longtext%%\' THEN \'longtext\' + WHEN LOWER(%1$s) LIKE \'mediumtext%%\' THEN \'mediumtext\' + WHEN LOWER(%1$s) LIKE \'tinytext%%\' THEN \'tinytext\' + WHEN LOWER(%1$s) LIKE \'text%%\' THEN \'text\' + ELSE LOWER(%1$s) +END', + $column_type_sql, + $fallback_sql + ); + } + private function get_direct_information_schema_character_set_expression( string $column_type_sql, string $metadata_sql, ?string $column_comment_sql = null, ?string $default_charset_sql = null ): string { + $comment_charset_sql = null === $column_comment_sql + ? 'NULL' + : $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_CHARSET_PREFIX ); + $default_charset_sql = $default_charset_sql ?? $this->connection->quote( $this->charset ); + return sprintf( + 'CASE + WHEN LOWER(%1$s) LIKE \'char%%\' + OR LOWER(%1$s) LIKE \'varchar%%\' + OR LOWER(%1$s) LIKE \'enum%%\' + OR LOWER(%1$s) LIKE \'set%%\' + OR LOWER(%1$s) LIKE \'%%text%%\' THEN COALESCE(%4$s, %2$s, %3$s) + ELSE NULL + END', + $column_type_sql, + $metadata_sql, + $default_charset_sql, + $comment_charset_sql + ); + } + private function get_direct_information_schema_collation_expression( string $column_type_sql, string $metadata_sql, ?string $column_comment_sql = null, ?string $default_collation_sql = null ): string { + $comment_collation_sql = null === $column_comment_sql + ? 'NULL' + : $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_COLLATION_PREFIX ); + $default_collation_sql = $default_collation_sql ?? $this->connection->quote( $this->collation ); + return sprintf( + 'CASE + WHEN LOWER(%1$s) LIKE \'char%%\' + OR LOWER(%1$s) LIKE \'varchar%%\' + OR LOWER(%1$s) LIKE \'enum%%\' + OR LOWER(%1$s) LIKE \'set%%\' + OR LOWER(%1$s) LIKE \'%%text%%\' THEN COALESCE(%4$s, %2$s, %3$s) + ELSE NULL + END', + $column_type_sql, + $metadata_sql, + $default_collation_sql, + $comment_collation_sql + ); + } + private function get_direct_information_schema_column_extra_expression( string $alias, bool $include_postgresql_catalog_triggers = false, ?string $column_comment_sql = null ): string { + if ( $include_postgresql_catalog_triggers ) { + $default_generated = $this->get_postgresql_catalog_current_timestamp_default_condition_sql( $alias ); + if ( null !== $column_comment_sql ) { + $default_generated = sprintf( + '(%1$s OR %2$s IS NOT NULL)', + $default_generated, + $this->get_postgresql_catalog_column_comment_marker_sql( $column_comment_sql, self::MYSQL_COLUMN_COMMENT_DEFAULT_PREFIX ) + ); + } + + $on_update_trigger = sprintf( + 'EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_trigger tr + ON tr.tgrelid = t.oid + WHERE n.nspname = %1$s.table_schema + AND t.relname = %1$s.table_name + AND tr.tgname = \'__wp_pg_on_update_\' || %2$s + AND NOT tr.tgisinternal + )', + $alias, + $this->get_postgresql_on_update_current_timestamp_trigger_hash_sql( + $alias . '.table_schema', + $alias . '.table_name', + $alias . '.column_name' + ) + ); + return sprintf( + 'CASE + WHEN %1$s.is_identity = \'YES\' THEN \'auto_increment\' + WHEN LOWER(COALESCE(%1$s.column_default, \'\')) LIKE \'nextval(%%\' THEN \'auto_increment\' + ELSE COALESCE( + NULLIF( + CONCAT_WS( + \' \', + CASE WHEN %2$s THEN \'DEFAULT_GENERATED\' ELSE NULL END, + CASE WHEN %3$s THEN \'on update CURRENT_TIMESTAMP\' ELSE NULL END + ), + \'\' + ), + \'\' + ) +END', + $alias, + $default_generated, + $on_update_trigger + ); + } + return sprintf( + 'CASE + WHEN %1$s.is_identity = \'YES\' THEN \'auto_increment\' + WHEN LOWER(COALESCE(%1$s.column_default, \'\')) LIKE \'nextval(%%\' THEN \'auto_increment\' + ELSE \'\' +END', + $alias + ); + } + private function translate_distinct_order_by_query( string $query, bool $include_limit = true, ?array &$query_context = null ): ?string { + $select = $this->get_sql_calc_found_rows_select_parts( $query, true, false, $query_context ); + if ( null === $select || ! $select['has_distinct'] ) { + return null; + } + + $tokens = $select['tokens']; + $projection_start = $select['projection_start']; + if ( + isset( $tokens[ $projection_start ] ) + && in_array( $tokens[ $projection_start ]->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 ) + ) { + return null; + } + + $clauses = $select['clauses']; + $has_sql_calc_found_rows = $select['has_sql_calc_found_rows']; + $limit_position = $select['limit_position']; + $order_position = $select['order_position']; + $select_end = $select['select_end']; + $statement_end = $select['statement_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 ); + return $this->append_mysql_select_limit_sql( $sql, $tokens, $limit_position, $statement_end, $include_limit ); + } + + $from_position = $clauses['from_position']; + if ( + null === $from_position + || $from_position >= $order_position + || ! $this->is_valid_mysql_select_order_by_clause( $tokens, $order_position, $select_end ) + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + $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 = null !== $clauses['where_position'] && $clauses['where_position'] < $order_position + ? $clauses['where_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; + } + + if ( ! $this->has_mysql_hidden_order_expression( $order_items ) ) { + if ( ! $has_sql_calc_found_rows ) { + return null; + } + + $sql = 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select_end ); + return $this->append_mysql_select_limit_sql( $sql, $tokens, $limit_position, $statement_end, $include_limit ); + } + return $this->build_distinct_order_by_grouped_query( + $tokens, + $projection_items, + $order_items, + $from_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + private function get_mysql_select_clause_positions( array $tokens, int $projection_start, int $statement_end, bool $require_supported_limit = true ): ?array { + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $projection_start, $statement_end ); + if ( $require_supported_limit && null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $projection_end = $this->find_first_top_level_mysql_token( $tokens, self::MYSQL_SELECT_PROJECTION_BOUNDARY_TOKENS, $projection_start, $statement_end ) ?? $select_end; + $clause_positions = array(); + foreach ( self::MYSQL_SELECT_CLAUSE_POSITION_TOKENS as $clause => $token ) { + $clause_positions[ $clause ] = $this->find_top_level_mysql_token( $tokens, $token, $projection_start, $select_end ); + } + $clause_ends = array(); + foreach ( self::MYSQL_SELECT_CLAUSE_END_DESCRIPTORS as $clause => $descriptor ) { + $position = $clause_positions[ $clause ]; + $clause_ends[ $clause ] = null === $position ? null : ( $this->find_first_top_level_mysql_token( $tokens, $descriptor[1], $position + $descriptor[0], $statement_end ) ?? $select_end ); + } + + return array( + 'from_end' => $clause_ends['from'], + 'from_position' => $clause_positions['from'], + 'group_end' => $clause_ends['group'], + 'group_position' => $clause_positions['group'], + 'having_end' => $clause_ends['having'], + 'having_position' => $clause_positions['having'], + 'limit_position' => $limit_position, + 'order_end' => $clause_ends['order'], + 'order_position' => $clause_positions['order'], + 'projection_end' => $projection_end, + 'select_end' => $select_end, + 'where_end' => $clause_ends['where'], + 'where_position' => $clause_positions['where'], + ); + } + private function get_mysql_query_context_select_clause_positions( array &$query_context, int $projection_start, int $statement_end, bool $require_supported_limit = true ): ?array { + $cache_key = $projection_start . ':' . $statement_end . ':' . ( $require_supported_limit ? '1' : '0' ); + if ( array_key_exists( $cache_key, $query_context['select_clause_positions'] ) ) { + return $query_context['select_clause_positions'][ $cache_key ]; + } + + $tokens = $query_context['tokens']; + $limit_position = $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::LIMIT_SYMBOL, $projection_start, $statement_end ); + if ( $require_supported_limit && null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + $query_context['select_clause_positions'][ $cache_key ] = null; + return null; + } + + $select_end = $limit_position ?? $statement_end; + $projection_end = $this->find_first_top_level_mysql_query_context_token( $query_context, self::MYSQL_SELECT_PROJECTION_BOUNDARY_TOKENS, $projection_start, $statement_end ) ?? $select_end; + $clause_positions = array(); + foreach ( self::MYSQL_SELECT_CLAUSE_POSITION_TOKENS as $clause => $token ) { + $clause_positions[ $clause ] = $this->find_top_level_mysql_query_context_token( $query_context, $token, $projection_start, $select_end ); + } + $clause_ends = array(); + foreach ( self::MYSQL_SELECT_CLAUSE_END_DESCRIPTORS as $clause => $descriptor ) { + $position = $clause_positions[ $clause ]; + $clause_ends[ $clause ] = null === $position ? null : ( $this->find_first_top_level_mysql_query_context_token( $query_context, $descriptor[1], $position + $descriptor[0], $statement_end ) ?? $select_end ); + } + + $query_context['select_clause_positions'][ $cache_key ] = array( + 'from_end' => $clause_ends['from'], + 'from_position' => $clause_positions['from'], + 'group_end' => $clause_ends['group'], + 'group_position' => $clause_positions['group'], + 'having_end' => $clause_ends['having'], + 'having_position' => $clause_positions['having'], + 'limit_position' => $limit_position, + 'order_end' => $clause_ends['order'], + 'order_position' => $clause_positions['order'], + 'projection_end' => $projection_end, + 'select_end' => $select_end, + 'where_end' => $clause_ends['where'], + 'where_position' => $clause_positions['where'], + ); + return $query_context['select_clause_positions'][ $cache_key ]; + } + private function is_valid_mysql_select_order_by_clause( array $tokens, ?int $order_position, int $order_end ): bool { + return null !== $order_position + && $order_position + 2 < $order_end + && isset( $tokens[ $order_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $order_position + 1 ]->id; + } + private function append_mysql_select_limit_sql( + string $sql, + array $tokens, + ?int $limit_position, + int $statement_end, + bool $include_limit + ): string { + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + return $sql; + } + 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; + } + 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; + } + 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, + ); + } + 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; + } + 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; + } + 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; + } + 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; + } + 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; + } + 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; + } + 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; + } + 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; + } + 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; + } + 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, + ?array $group_by_sql = null, + ?int $source_end_position = null, + bool $inner_distinct = false + ): 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(); + $source_end_position = $source_end_position ?? $order_position; + $derive_group_by_sql = null === $group_by_sql; + $group_by_sql = $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 + ); + if ( $derive_group_by_sql ) { + $group_by_sql[] = $projection_item['sql']; + } + } + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + continue; + } + + $inner_projection_sql[] = sprintf( + '%s(%s) AS %s', + 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN', + $order_item['sql'], + $this->connection->quote_identifier( '__wp_pg_order_' . $index ) + ); + } + + $sql = sprintf( + 'SELECT %s FROM (SELECT %s%s %s GROUP BY %s) AS %s ORDER BY %s', + implode( ', ', $outer_projection_sql ), + $inner_distinct ? 'DISTINCT ' : '', + implode( ', ', $inner_projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $source_end_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; + } + 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 = '__wp_pg_order_' . $index; + } + + $order_sql[] = sprintf( + '%s.%s %s', + $quoted_derived_table_alias, + $this->connection->quote_identifier( $order_alias ), + $order_item['direction'] + ); + } + return implode( ', ', $order_sql ); + } + private function translate_strict_aggregate_grouped_order_by_query( string $query, bool $include_limit = true, ?array &$query_context = null ): ?string { + $select = $this->get_sql_calc_found_rows_select_parts( $query, true, false, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + $projection_start = $select['projection_start']; + $has_distinct = $select['has_distinct']; + $clauses = $select['clauses']; + $limit_position = $select['limit_position']; + $order_position = $select['order_position']; + $select_end = $select['select_end']; + if ( + ! $this->is_valid_mysql_select_order_by_clause( $tokens, $order_position, $select_end ) + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + $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 = null !== $clauses['group_position'] && $clauses['group_position'] < $order_position + ? $clauses['group_position'] + : null; + if ( null === $group_position ) { + return $this->translate_strict_aggregate_only_order_by_query( + $tokens, + $projection_start, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + return $this->translate_strict_grouped_order_by_query( + $tokens, + $projection_start, + $group_position, + $order_position, + $limit_position, + $statement_end, + $has_distinct, + $include_limit + ); + } + 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 ); + return $this->append_mysql_select_limit_sql( $sql, $tokens, $limit_position, $statement_end, $include_limit ); + } + 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 { + $context = $this->get_mysql_grouped_select_rewrite_context( + $tokens, + $projection_start, + $group_position, + $order_position, + true + ); + if ( null === $context ) { + return null; + } + + $projection_items = $context['projection_items']; + $group_items = $context['group_items']; + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $order_position + 2, + $limit_position ?? $statement_end, + $projection_items, + $context['scope'] + ); + if ( null === $order_items ) { + return null; + } + + $archive_date_expression = $this->get_mysql_archive_grouped_date_expression_bounds( $tokens, $group_items ); + $wordpress_id_group = $this->get_mysql_wordpress_grouped_id_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, + $context['from_position'], + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + if ( null === $archive_date_expression && null === $wordpress_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_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $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 ) ) + || ( null !== $wordpress_id_group && $this->is_mysql_wordpress_grouped_id_order_expression( $tokens, $order_item, $wordpress_id_group ) ) + ) { + $order_sql[] = sprintf( '%s(%s) %s', 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN', $order_item['sql'], $order_item['direction'] ); + $rewritten = true; + continue; + } + return null; + } + + $tiebreaker_sql = $this->get_strict_grouped_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, $order_items, $group_items, 'posts' === $wordpress_id_group ); + if ( null !== $tiebreaker_sql ) { + $order_sql[] = $tiebreaker_sql; + $rewritten = true; + } + + if ( ! $rewritten ) { + return null; + } + + $replacements = array(); + if ( null !== $archive_date_expression ) { + $replacements = array_merge( + $replacements, + $this->get_mysql_archive_grouped_projection_replacements( + $tokens, + $projection_items, + $archive_date_expression + ) + ); + } + + $where_replacement = $this->get_mysql_grouped_where_predicate_replacement( $tokens, $context, $group_position ); + if ( null !== $where_replacement ) { + $replacements[] = $where_replacement; + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $projection_start, + $order_position, + $replacements + ) + . ' ORDER BY ' . implode( ', ', $order_sql ); + return $this->append_mysql_select_limit_sql( $sql, $tokens, $limit_position, $statement_end, $include_limit ); + } + 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; + } + + if ( ! $this->has_mysql_hidden_order_expression( $order_items ) ) { + 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 ) + ) { + $group_by_sql = $this->get_mysql_group_by_item_sql( $tokens, $group_items ); + } else { + $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_order_by_grouped_query( + $tokens, + $projection_items, + $order_items, + $from_position, + $order_position, + $limit_position, + $statement_end, + $include_limit, + $group_by_sql, + $group_position, + true + ); + } + private function has_mysql_hidden_order_expression( array $order_items ): bool { + foreach ( $order_items as $order_item ) { + if ( null === $order_item['projection_index'] ) { + return true; + } + } + return false; + } + 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; + } + 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; + } + 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 ( 6 !== count( $projection_items ) || 1 !== count( $group_items ) || 1 !== count( $order_items ) || null !== $order_items[0]['projection_index'] ) { + return null; + } + + $expected_columns = array( + 't.term_id' => array( 't', 'term_id', 'term_id' ), + 'tt.term_taxonomy_id' => array( 'tt', 'term_taxonomy_id', 'term_taxonomy_id' ), + 'tt.taxonomy' => array( 'tt', 'taxonomy', 'taxonomy' ), + 'tt.description' => array( 'tt', 'description', 'description' ), + 'tt.parent' => array( 'tt', 'parent', 'parent' ), + ); + foreach ( array_values( $expected_columns ) as $index => $expected_column ) { + if ( ! $this->is_mysql_qualified_column_projection( $tokens, $projection_items[ $index ], $expected_column[0], $expected_column[1], $expected_column[2] ) ) { + return null; + } + } + + if ( 'count' !== strtolower( $projection_items[5]['alias'] ) ) { + return null; + } + $count_bounds = $this->normalize_mysql_expression_bounds( + $tokens, + $projection_items[5]['expression_start'], + $projection_items[5]['expression_end'] + ); + if ( + ! isset( $tokens[ $count_bounds['start'] ], $tokens[ $count_bounds['start'] + 1 ] ) + || ! $this->is_mysql_token_value( $tokens[ $count_bounds['start'] ], 'count' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $count_bounds['start'] + 1 ]->id + || $this->get_mysql_parenthesized_sequence_end( $tokens, $count_bounds['start'] + 1, $count_bounds['end'] ) !== $count_bounds['end'] + || ! $this->is_mysql_qualified_column_range( $tokens, $count_bounds['start'] + 2, $count_bounds['end'] - 1, 'p', 'post_type' ) + ) { + return null; + } + + if ( + ! $this->is_mysql_qualified_column_range( $tokens, $group_items[0]['start'], $group_items[0]['end'], 't', 'term_id' ) + || ! $this->is_mysql_qualified_column_range( $tokens, $order_items[0]['expression_start'], $order_items[0]['expression_end'], 't', 'name' ) + ) { + 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 ) + ) { + return null; + } + + $taxonomy_predicate_matched = false; + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $where_position + 1, $group_position ); + if ( null === $conjuncts ) { + return null; + } + foreach ( $conjuncts as $conjunct ) { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $conjunct['start'], $conjunct['end'] ); + $reference = $this->parse_mysql_column_reference( $tokens, $bounds['start'], $bounds['end'] ); + if ( + null === $reference + || $reference['end'] >= $bounds['end'] + || 'tt' !== strtolower( (string) $reference['qualifier'] ) + || 'taxonomy' !== strtolower( $reference['column'] ) + ) { + continue; + } + + $matched = WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $reference['end'] ]->id + && $this->is_mysql_string_literal_range( $tokens, $reference['end'] + 1, $bounds['end'] ); + if ( ! $matched && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $reference['end'] ]->id && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $reference['end'] + 1 ]->id ?? null ) ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $bounds['end'] ); + $items = $after_close === $bounds['end'] + ? $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $bounds['end'] - 1 ) + : null; + $matched = null !== $items + && 1 === count( $items ) + && $this->is_mysql_string_literal_range( $tokens, $items[0]['start'], $items[0]['end'] ); + } + + if ( ! $matched ) { + continue; + } + + if ( $taxonomy_predicate_matched ) { + return null; + } + $taxonomy_predicate_matched = true; + } + if ( ! $taxonomy_predicate_matched ) { + return null; + } + return array_keys( $expected_columns ); + } + private function is_mysql_qualified_column_projection( array $tokens, array $projection_item, string $qualifier, string $column, string $alias ): bool { + return strtolower( (string) $projection_item['alias'] ) === $alias + && $this->is_mysql_qualified_column_range( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + $qualifier, + $column + ); + } + private function is_mysql_qualified_column_range( array $tokens, int $start, int $end, string $qualifier, string $column_name ): bool { + $column = $this->get_mysql_simple_qualified_column_expression( $tokens, $start, $end ); + return null !== $column && $column['qualifier'] === $qualifier && $column['column'] === $column_name; + } + 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 ); + } + private function translate_grouped_having_alias_query( string $query, ?array &$query_context = null ): ?string { + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( + $this->contains_top_level_mysql_query_context_token( + $query_context, + 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; + } + + $clauses = $this->get_mysql_query_context_select_clause_positions( $query_context, 1, $statement_end ); + if ( null === $clauses ) { + return null; + } + + $order_position = $clauses['order_position']; + if ( + null !== $order_position + && ! $this->is_valid_mysql_select_order_by_clause( $tokens, $order_position, $clauses['select_end'] ) + ) { + return null; + } + + $having_position = $clauses['having_position']; + $having_end = $clauses['having_end']; + if ( + null === $having_position + || null === $having_end + || ( null !== $order_position && $having_position > $order_position ) + || $having_position + 1 >= $having_end + ) { + return null; + } + + $group_position = $clauses['group_position']; + if ( null === $group_position || $group_position > $having_position ) { + return null; + } + + $context = $this->get_mysql_grouped_select_rewrite_context( + $tokens, + 1, + $group_position, + $having_position, + false + ); + if ( null === $context ) { + return null; + } + + $alias_expressions = $this->get_mysql_grouped_having_projection_alias_expressions( + $tokens, + $context['projection_items'], + $context['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_replacement = $this->get_mysql_grouped_where_predicate_replacement( $tokens, $context, $group_position ); + if ( null !== $where_replacement ) { + $replacements[] = $where_replacement; + } + + $group_by_extensions = $this->get_mysql_grouped_having_group_by_projection_extensions( + $tokens, + $context['projection_items'], + $context['from_position'], + $group_position, + $context['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 + ); + } + private function get_mysql_grouped_select_rewrite_context( array $tokens, int $projection_start, int $group_position, int $group_end, bool $parse_projection_items ): ?array { + if ( ! isset( $tokens[ $group_position + 1 ] ) || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id || $group_position + 2 >= $group_end ) { + return null; + } + + $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 || $from_position > $group_position ) { + return null; + } + + $projection_items = $parse_projection_items + ? $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ) + : $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $group_end ); + if ( null === $projection_items || null === $group_items || count( $group_items ) < 1 ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $where_position ?? $group_position ); + if ( null === $scope ) { + return null; + } + + return array( + 'from_position' => $from_position, + 'group_items' => $group_items, + 'projection_items' => $projection_items, + 'scope' => $scope, + 'where_position' => $where_position, + ); + } + private function get_mysql_grouped_where_predicate_replacement( array $tokens, array $context, int $group_position ): ?array { + if ( null === $context['where_position'] ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $context['where_position'] + 1, + $group_position, + $context['scope'] + ); + if ( ! $where_sql['changed'] ) { + return null; + } + return array( + 'start' => $context['where_position'] + 1, + 'end' => $group_position, + 'sql' => $where_sql['sql'], + ); + } + private function get_mysql_grouped_having_projection_alias_expressions( array $tokens, array $projection_ranges, array $group_items ): ?array { + $aliases = array(); + foreach ( $projection_ranges as $range ) { + $descriptor = $this->get_mysql_grouped_having_projection_descriptor( $tokens, $range, $group_items ); + if ( null === $descriptor || null === $descriptor['alias'] ) { + continue; + } + + $alias_key = strtolower( $descriptor['alias'] ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + + if ( ! $descriptor['is_aggregate'] && ! $descriptor['is_grouped'] ) { + continue; + } + + $aliases[ $alias_key ] = array( 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $descriptor['expression_start'], $descriptor['expression_end'] ) ); + } + return $aliases; + } + private function get_mysql_grouped_having_group_by_projection_extensions( + array $tokens, + array $projection_items, + int $from_position, + int $group_position, + array $group_items + ): ?array { + $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 ) { + $descriptor = $this->get_mysql_grouped_having_projection_descriptor( $tokens, $projection_item, $group_items ); + if ( null === $descriptor ) { + continue; + } + + $projection_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $descriptor['expression_start'], $descriptor['expression_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 ( + $projection_column['key'] !== $grouped_column['key'] + && ! isset( $equivalent_columns[ $projection_column['key'] ][ $grouped_column['key'] ] ) + ) { + 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; + } + 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'] ); + } + 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 ), + ); + } + 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; + } + 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; + } + 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, + ), + ); + } + 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 ); + } + 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; + } + private function is_wordpress_options_table_name( string $table_name ): bool { + return $this->is_mysql_wordpress_table_name( $table_name, 'options' ); + } + 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'] + ); + } + 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; + } + 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; + } + 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; + } + 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, + ); + } + 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; + } + } + } + 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; + } + private function get_mysql_grouped_having_projection_descriptor( array $tokens, array $projection_item, array $group_items ): ?array { + $bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $projection_item['start'], $projection_item['end'] ); + if ( null === $bounds ) { + return null; + } + + if ( isset( $tokens[ $bounds['end'] ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $bounds['end'] ]->id ) { + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $bounds['end'] + 1 ] ?? null ); + } else { + $alias = $this->get_mysql_implicit_projection_alias( $tokens, $projection_item['start'], $projection_item['end'] ); + } + + return array( + 'expression_start' => $bounds['start'], + 'expression_end' => $bounds['end'], + 'alias' => $alias, + 'is_aggregate' => $this->contains_mysql_aggregate_call( $tokens, $bounds['start'], $bounds['end'] ), + 'is_grouped' => $this->is_mysql_grouped_expression( $tokens, $bounds['start'], $bounds['end'], $group_items ), + ); + } + private function is_mysql_grouped_expression( array $tokens, int $start, int $end, array $group_items ): bool { + 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; + } + private function contains_mysql_aggregate_call( array $tokens, int $start, int $end ): bool { + 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, self::MYSQL_AGGREGATE_CALL_TOKEN_IDS, true ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return true; + } + } + return false; + } + 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' ) ); + } + 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; + } + 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; + } + 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 + ); + } + 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->is_mysql_archive_date_expression_span( $tokens, $expression['start'], $expression['end'], $archive_date_expression ); + } + 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->is_mysql_archive_date_expression_span( $tokens, $expression['start'], $expression['end'], $archive_date_expression ); + } + 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->is_mysql_archive_date_expression_span( $tokens, $bounds['expression_start'], $bounds['expression_end'], $archive_date_expression ); + } + private function is_mysql_archive_date_expression_span( array $tokens, int $expression_start, int $expression_end, array $archive_date_expression ): bool { + return $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $expression_start, + $expression_end + ); + } + 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; + } + 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'] + ); + } + 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, + ); + } + 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; + } + 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; + } + + $expressions = array( + 'year' => null, + 'week' => null, + 'month' => null, + 'dayofmonth' => null, + ); + $descriptors = array( + array( 'year', 'extract', 'YEAR' ), + array( 'week', 'week' ), + array( 'month', 'extract', 'MONTH' ), + array( 'dayofmonth', 'extract', 'DAY' ), + ); + $supported_expressions = 0; + foreach ( $group_items as $group_item ) { + foreach ( $descriptors as $descriptor ) { + $expression = 'week' === $descriptor[1] + ? $this->get_mysql_week_argument_expression_bounds( $tokens, $group_item['start'], $group_item['end'] ) + : $this->get_mysql_extract_argument_expression_bounds( $tokens, $group_item['start'], $group_item['end'], $descriptor[2] ); + if ( null !== $expression ) { + $expressions[ $descriptor[0] ] = $expression; + ++$supported_expressions; + continue 2; + } + } + } + + if ( + $group_count !== $supported_expressions + || null === $expressions['year'] + ) { + return null; + } + + $year_expression = $expressions['year']; + if ( null !== $expressions['week'] ) { + if ( + 2 !== $group_count + || null !== $expressions['month'] + || null !== $expressions['dayofmonth'] + || ! $this->are_mysql_expression_bounds_equivalent( $tokens, $year_expression, $expressions['week'] ) + ) { + return null; + } + } elseif ( + ( + 2 <= $group_count + && null === $expressions['month'] + ) + || ( + 3 === $group_count + && null === $expressions['dayofmonth'] + ) + || ! $this->are_mysql_expression_bounds_equivalent( $tokens, $year_expression, $expressions['month'] ) + || ! $this->are_mysql_expression_bounds_equivalent( $tokens, $year_expression, $expressions['dayofmonth'] ) + ) { + 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; + } + private function are_mysql_expression_bounds_equivalent( array $tokens, array $left, ?array $right ): bool { + return null === $right || $this->are_mysql_token_ranges_equivalent( + $tokens, + $left['start'], + $left['end'], + $right['start'], + $right['end'] + ); + } + 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'], + ); + } + 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'], + ); + } + 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 ); + } + private function get_mysql_wordpress_grouped_id_select_shape( array $tokens, array $projection_items, array $group_items ): ?string { + if ( 1 !== count( $projection_items ) || 1 !== count( $group_items ) ) { + return null; + } + + foreach ( + array( + array( 'comments', 'comment_ID' ), + array( 'posts', 'ID' ), + ) as $shape + ) { + if ( + $this->is_mysql_column_reference_expression( $tokens, $projection_items[0]['expression_start'], $projection_items[0]['expression_end'], $shape[1], $shape[0], true ) + && $this->is_mysql_column_reference_expression( $tokens, $group_items[0]['start'], $group_items[0]['end'], $shape[1], $shape[0], true ) + ) { + return $shape[0]; + } + } + return null; + } + private function is_mysql_wordpress_grouped_id_order_expression( array $tokens, array $order_item, string $group ): bool { + $columns = 'comments' === $group + ? array( 'comment_date', 'comment_date_gmt' ) + : array( 'post_date', 'post_date_gmt' ); + foreach ( $columns as $column ) { + if ( + $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $column, + $group, + false + ) + ) { + return true; + } + } + + if ( + $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_typed_cast_bounds( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'comments' === $group ? array( 'character' ) : array( 'character', 'integer', 'decimal', 'date_time' ) + ); + if ( + null !== $cast_bounds + && $cast_bounds['close'] + 1 === $order_item['expression_end'] + && $this->is_mysql_qualified_column_reference_expression( + $tokens, + $cast_bounds['expression_start'], + $cast_bounds['expression_end'], + 'meta_value' + ) + ) { + return true; + } + return 'posts' === $group + && $this->is_mysql_meta_value_plus_zero_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + } + 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' ); + } + 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'; + } + 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 ) ); + } + 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 ); + } + 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; + } + 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, + ); + } + 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() ); + } + private function translate_sql_calc_found_rows_select_query( string $query, bool $include_limit = true, ?array &$query_context = null ): ?string { + $select = $this->get_sql_calc_found_rows_select_parts( $query, false, true, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $projection_start = $select['projection_start']; + + $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( + $tokens, + $projection_start, + $select['select_end'], + false + ); + if ( null !== $contextual_sql ) { + return $this->append_mysql_select_limit_sql( $contextual_sql, $tokens, $select['limit_position'], $select['statement_end'], $include_limit ); + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select['select_end'] ); + return $this->append_mysql_select_limit_sql( $sql, $tokens, $select['limit_position'], $select['statement_end'], $include_limit ); + } + private function translate_mysql_compatible_query( string $query, ?array &$query_context = null ): ?string { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( + ! in_array( + $tokens[0]->id, + self::MYSQL_COMPATIBLE_REWRITE_STATEMENT_TOKENS, + true + ) + ) { + return null; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( + $tokens, + 1, + $statement_end, + true + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } + + $contextual_sql = $this->translate_mysql_count_aggregate_projection_alias_query( + $tokens, + 1, + $statement_end + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } + } + + if ( $this->contains_unsupported_mysql_rewrite_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( ! $this->needs_mysql_compatible_rewrite( $tokens, 0, $statement_end ) ) { + return null; + } + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); + } + private function contains_unsupported_mysql_rewrite_function( array $tokens, int $start, int $end, bool $include_group_concat = false ): bool { + foreach ( self::MYSQL_COMPATIBLE_REWRITE_UNSUPPORTED_FUNCTION_SCANNERS as $scanner_method ) { + if ( $this->$scanner_method( $tokens, $start, $end ) ) { + return true; + } + } + return $include_group_concat && $this->contains_unsupported_mysql_group_concat_function( $tokens, $start, $end ); + } + private function should_reject_information_schema_backend_query( string $query, ?array &$query_context = null ): bool { + if ( + null === $query_context + && 0 !== strcasecmp( $this->db_name, 'information_schema' ) + && ! in_array( + $this->get_mysql_query_context_first_token_id( $query, $query_context ), + array( WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::INSERT_SYMBOL ), + true + ) + ) { + return false; + } + + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + $pre_context_rejection_guards = array( + WP_MySQL_Lexer::SELECT_SYMBOL => 'get_information_schema_backend_select_rejection', + WP_MySQL_Lexer::INSERT_SYMBOL => 'get_information_schema_backend_insert_select_rejection', + ); + if ( isset( $pre_context_rejection_guards[ $tokens[0]->id ] ) ) { + $decision = $this->{$pre_context_rejection_guards[ $tokens[0]->id ]}( $query, $tokens, $statement_end ); + if ( null !== $decision ) { + return $decision; + } + } + + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + $context_allow_guards = array( + WP_MySQL_Lexer::DESCRIBE_SYMBOL => 'information_schema_backend_describe_query_is_allowed', + WP_MySQL_Lexer::DESC_SYMBOL => 'information_schema_backend_describe_query_is_allowed', + ); + if ( isset( $context_allow_guards[ $tokens[0]->id ] ) && $this->{$context_allow_guards[ $tokens[0]->id ]}( $tokens ) ) { + return false; + } + + if ( in_array( $tokens[0]->id, self::MYSQL_INFORMATION_SCHEMA_BACKEND_CONTEXT_REJECT_TOKENS, true ) ) { + return true; + } + + if ( $this->information_schema_write_query_targets_main_database_explicitly( $query, $tokens ) ) { + return false; + } + return in_array( $tokens[0]->id, self::MYSQL_INFORMATION_SCHEMA_BACKEND_WRITE_ADMIN_REJECT_TOKENS, true ); + } + private function get_information_schema_backend_select_rejection( string $query, array $tokens, ?int $statement_end ): ?bool { + if ( null !== $statement_end ) { + if ( null !== $this->translate_direct_information_schema_select_query( $query ) ) { + return false; + } + + if ( $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) ) { + return true; + } + + if ( $this->information_schema_select_query_targets_main_database_explicitly( $query, $tokens, $statement_end ) ) { + return false; + } + } + return 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->information_schema_select_has_table_reference( $tokens ); + } + private function get_information_schema_backend_insert_select_rejection( string $query, array $tokens, ?int $statement_end ): ?bool { + if ( null === $statement_end || ! $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) ) { + return null; + } + return null === $this->translate_simple_mysql_insert_select_query( $query ); + } + private function information_schema_backend_describe_query_is_allowed( array $tokens ): bool { + $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 ) ) { + return false; + } + return $this->is_explicit_main_database_table_reference( $table_reference ) + || ( + ( null === $table_reference['schema'] || 0 === strcasecmp( $table_reference['schema'], 'information_schema' ) ) + && null !== $this->get_direct_information_schema_relation_columns( $table_reference['table'] ) + ); + } + private function information_schema_write_query_targets_main_database_explicitly( string $query, array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $target_guards = array( + WP_MySQL_Lexer::INSERT_SYMBOL => array( 'insert_or_replace_query_targets_main_database_explicitly', array( $tokens, true ) ), + WP_MySQL_Lexer::REPLACE_SYMBOL => array( 'insert_or_replace_query_targets_main_database_explicitly', array( $tokens, false ) ), + WP_MySQL_Lexer::UPDATE_SYMBOL => array( 'simple_update_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::DELETE_SYMBOL => array( 'simple_delete_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::TRUNCATE_SYMBOL => array( 'truncate_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::CREATE_SYMBOL => array( 'create_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::ALTER_SYMBOL => array( 'alter_table_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::DROP_SYMBOL => array( 'drop_query_targets_main_database_explicitly', array( $tokens ) ), + WP_MySQL_Lexer::ANALYZE_SYMBOL => array( 'table_administration_query_targets_main_database_explicitly', array( $query ) ), + WP_MySQL_Lexer::CHECK_SYMBOL => array( 'table_administration_query_targets_main_database_explicitly', array( $query ) ), + WP_MySQL_Lexer::OPTIMIZE_SYMBOL => array( 'table_administration_query_targets_main_database_explicitly', array( $query ) ), + WP_MySQL_Lexer::REPAIR_SYMBOL => array( 'table_administration_query_targets_main_database_explicitly', array( $query ) ), + WP_MySQL_Lexer::LOCK_SYMBOL => array( 'lock_tables_query_targets_main_database_explicitly', array( $query ) ), + ); + if ( ! isset( $target_guards[ $tokens[0]->id ] ) ) { + return false; + } + + $target_guard = $target_guards[ $tokens[0]->id ]; + return $this->{$target_guard[0]}( ...$target_guard[1] ); + } + private function is_explicit_main_database_table_reference( ?array $table_reference ): bool { + if ( null === $table_reference || null === $table_reference['schema'] ) { + return false; + } + return 0 === strcasecmp( $table_reference['schema'], $this->main_db_name ) + || 0 === strcasecmp( $table_reference['schema'], 'public' ); + } + private function mysql_table_reference_targets_main_database_explicitly( array $tokens, int &$position, bool $allow_double_quoted = false ): bool { + return $this->is_explicit_main_database_table_reference( $this->get_mysql_table_administration_table_reference( $tokens, $position, $allow_double_quoted ) ); + } + private function insert_or_replace_query_targets_main_database_explicitly( array $tokens, bool $is_insert ): bool { + $position = 1; + if ( $is_insert ) { + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + } else { + $this->consume_mysql_replace_priority_modifier( $tokens, $position ); + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + private function simple_update_query_targets_main_database_explicitly( array $tokens ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $statement_end ); + if ( ! $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ) ) { + return false; + } + return $this->consume_optional_simple_table_alias( $tokens, $position, $statement_end ) + && isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id; + } + private function simple_delete_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return false; + } + + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ) + && $this->consume_optional_simple_table_alias( $tokens, $position, $statement_end ); + } + private function consume_optional_simple_table_alias( array $tokens, int &$position, int $statement_end ): bool { + if ( $position + 1 < $statement_end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) ) { + return false; + } + + $position += 2; + return true; + } + + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + ++$position; + } + return true; + } + private function truncate_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + private function create_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + + while ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::UNIQUE_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + ), + true + ) + ) { + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id + ) { + return false; + } + + ++$position; + $this->consume_mysql_if_not_exists_sequence( $tokens, $position ); + + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, true ) ) { + return false; + } + ++$position; + + while ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::USING_SYMBOL, WP_MySQL_Lexer::TYPE_SYMBOL ), true ) + ) { + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position, true ); + } + private function alter_table_query_targets_main_database_explicitly( array $tokens ): bool { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[1]->id ) { + $position = 2; + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null !== $statement_end && $this->contains_mysql_unsupported_view_prefix_clause( $tokens, 1, $statement_end ) ) { + for ( $position = 1; $position < $statement_end; $position++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + ++$position; + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id ) { + return false; + } + + $position = 2; + $table_reference = $this->get_mysql_dbdelta_alter_table_target_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + private function drop_query_targets_main_database_explicitly( array $tokens ): bool { + if ( + isset( $tokens[1] ) + && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::VIEW_SYMBOL ), true ) + ) { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return false; + } + + $position = 2; + $this->consume_mysql_if_exists_sequence( $tokens, $position ); + + $matched = false; + while ( $position < $statement_end ) { + if ( ! $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ) ) { + return false; + } + + $matched = true; + if ( $position === $statement_end ) { + break; + } + + if ( + WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[1]->id + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) + ) { + ++$position; + return $position === $statement_end; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + ++$position; + } + return $matched; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id ) { + return false; + } + + $position = 2; + if ( null === $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + ++$position; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + return $this->mysql_table_reference_targets_main_database_explicitly( $tokens, $position ); + } + private function table_administration_query_targets_main_database_explicitly( string $query ): bool { + try { + $table_administration_query = $this->get_mysql_table_administration_query( $query ); + } catch ( InvalidArgumentException $e ) { + return false; + } + + return $this->table_references_target_main_database_explicitly( $table_administration_query['tables'] ?? array() ); + } + private function lock_tables_query_targets_main_database_explicitly( string $query ): bool { + try { + $lock_tables_query = $this->get_mysql_lock_tables_query( $query ); + } catch ( InvalidArgumentException $e ) { + return false; + } + + if ( null === $lock_tables_query || 'lock' !== ( $lock_tables_query['operation'] ?? null ) ) { + return false; + } + + return $this->table_references_target_main_database_explicitly( $lock_tables_query['tables'] ?? array() ); + } + private function table_references_target_main_database_explicitly( array $table_references ): bool { + foreach ( $table_references as $table_reference ) { + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + } + return ! empty( $table_references ); + } + private function translate_information_schema_main_database_select_query( string $query, ?array &$query_context = null ): ?string { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return null; + } + + $select = $this->get_mysql_top_level_select_parts( $query, 1, $query_context ); + if ( null === $select ) { + return null; + } + + $tokens = $select['tokens']; + $statement_end = $select['statement_end']; + if ( + $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) + || $this->contains_top_level_mysql_query_context_token( + $query_context, + 1, + $statement_end, + array( WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::INTO_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL ) + ) + ) { + return null; + } + + if ( $this->contains_unsupported_mysql_rewrite_function( $tokens, 0, $statement_end, true ) ) { + return null; + } + + $from_position = $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position ) { + $nested_select_replacements = $this->get_information_schema_nested_select_replacements( + $query, + $tokens, + array( + array( + 'start' => 1, + 'end' => $statement_end, + ), + ), + true + ); + if ( + null === $nested_select_replacements + || array() === $nested_select_replacements + || ! $this->direct_information_schema_nested_selects_are_covered( $tokens, 1, $statement_end, $nested_select_replacements ) + ) { + return null; + } + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $nested_select_replacements + ); + } + + if ( 1 === $from_position ) { + return null; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::GROUP_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL, WP_MySQL_Lexer::WHERE_SYMBOL ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $replacements = $this->get_information_schema_main_database_select_table_replacements( + $query, + $tokens, + $from_position + 1, + $from_end + ); + if ( null === $replacements ) { + return null; + } + + $nested_select_ranges = array_merge( + array( + array( + 'start' => 1, + 'end' => $from_position, + ), + ), + $this->get_mysql_select_tail_clause_ranges( $tokens, $from_end, $statement_end ) + ); + + $nested_select_replacements = $this->get_information_schema_nested_select_replacements( + $query, + $tokens, + $nested_select_ranges, + true + ); + if ( null === $nested_select_replacements ) { + return null; + } + + $replacements = array_merge( $replacements, $nested_select_replacements ); + $this->sort_mysql_replacements( $replacements ); + + if ( ! $this->direct_information_schema_nested_selects_are_covered( $tokens, 1, $statement_end, $replacements ) ) { + return null; + } + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ); + } + private function get_information_schema_main_database_select_table_replacements( string $query, array $tokens, int $start, int $end ): ?array { + $position = $start; + $expect_next = true; + $replacements = array(); + + while ( $position < $end ) { + if ( $expect_next ) { + $source_replacement = $this->get_information_schema_main_database_select_source_replacement( $query, $tokens, $position, $end ); + if ( null === $source_replacement ) { + return null; + } + + $replacements[] = $source_replacement['replacement']; + $position = $source_replacement['position']; + $expect_next = false; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_next = true; + } + + ++$position; + } + return empty( $replacements ) || $expect_next ? null : $replacements; + } + private function get_information_schema_main_database_select_source_replacement( string $query, array $tokens, int $position, int $end ): ?array { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_information_schema_main_database_derived_select_replacement( $query, $tokens, $position, $end ); + } + + $reference_start = $position; + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( + null === $reference + || empty( $reference['schema_qualified'] ) + || ! $this->is_explicit_main_database_table_reference( $reference ) + ) { + return null; + } + return array( + 'replacement' => array( + 'start' => $reference_start, + 'end' => $reference_start + 3, + 'sql' => $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $reference_start + 2 ] ?? null ), + ), + 'position' => $reference['position'], + ); + } + private function get_information_schema_main_database_derived_select_replacement( string $query, array $tokens, int $position, int $end ): ?array { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( + null === $after_close + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_sql( $query, $tokens, $select_start, $select_end ); + if ( null === $select_query ) { + return null; + } + + $translated_select = $this->translate_information_schema_main_database_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + return array( + 'replacement' => array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $translated_select, + ), + 'position' => $this->skip_mysql_table_alias( $tokens, $after_close, $end ), + ); + } + private function information_schema_select_query_targets_main_database_explicitly( string $query, array $tokens, int $statement_end ): bool { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + if ( null !== $this->translate_information_schema_main_database_select_query( $query ) ) { + return true; + } + + if ( null === $this->translate_simple_mysql_select_query( $query ) ) { + return false; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position ) { + return false; + } + + $source_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $position = $from_position + 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + return $this->consume_optional_simple_table_alias( $tokens, $position, $source_end ) + && $position === $source_end; + } + private function information_schema_select_has_table_reference( array $tokens ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return true; + } + return $this->mysql_select_range_has_non_dual_table_reference( $tokens, 0, $statement_end ); + } + private function select_references_direct_information_schema_relation( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position + 2 < $end; $position++ ) { + $shape = $this->get_direct_information_schema_dotted_reference_shape( $tokens, $position, $end ); + if ( null !== $shape && 0 === strcasecmp( $shape['qualifier'], 'information_schema' ) ) { + return true; + } + } + return false; + } + 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 ) + ); + } + private function get_mysql_query_context( string $query, ?array &$query_context = null ): array { + if ( null !== $query_context ) { + $sql_mode = $this->get_sql_mode(); + if ( + ( $query_context['query'] ?? null ) === $query + && ( $query_context['sql_mode'] ?? null ) === $sql_mode + ) { + return $query_context; + } + } + + $query_context = $this->get_mysql_token_query_context( $this->get_mysql_tokens( $query ), $query, $this->mysql_token_cache_sql_mode ); + return $query_context; + } + private function get_mysql_query_context_tokens( string $query, ?array &$query_context = null ): array { + $this->get_mysql_query_context( $query, $query_context ); + return $query_context['tokens']; + } + private function get_mysql_query_context_first_token_id( string $query, ?array &$query_context = null ): ?int { + if ( null === $query_context ) { + return $this->get_mysql_tokens( $query )[0]->id ?? null; + } + + $this->get_mysql_query_context( $query, $query_context ); + return $query_context['first_token_id']; + } + private function get_mysql_token_query_context( array $tokens, ?string $query = null, ?string $sql_mode = null ): array { + return array( + 'first_token_id' => $tokens[0]->id ?? null, + 'query' => $query, + 'select_clause_positions' => array(), + 'sql_mode' => $sql_mode, + 'statement_end_positions' => array(), + 'tokens' => $tokens, + 'top_level_token_indexes' => array(), + ); + } + private function get_mysql_tokens( string $query ): array { + $sql_mode = $this->get_sql_mode(); + if ( $query === $this->mysql_token_cache_query && $sql_mode === $this->mysql_token_cache_sql_mode ) { + return $this->mysql_token_cache_tokens; + } + + $lexer = new WP_MySQL_Lexer( $query, $this->mysql_version, $this->active_sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + $this->mysql_token_cache_query = $query; + $this->mysql_token_cache_sql_mode = $sql_mode; + $this->mysql_token_cache_tokens = $tokens; + return $tokens; + } + private function parse_mysql_identifier_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $identifiers = array(); + + while ( isset( $tokens[ $position ] ) ) { + $identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $identifiers; + } + return null; + } + return null; + } + 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; + } + private function parse_upsert_update_assignments( string $table_name, array $tokens, int &$position, int $end, array $column_lookup, array $table_column_lookup, array $source_aliases = array(), ?array &$assignment_effects = null ): ?array { + $assignment_effects = array(); + $assignments = array(); + $scope = $this->get_mysql_single_table_scope( $table_name ); + $values_column_lookup = $this->get_mysql_upsert_values_column_lookup( $column_lookup, $table_column_lookup ); + + $assignment_ranges = $this->get_mysql_update_assignment_ranges( + $tokens, + $position, + $end, + function ( int $assignment_position ) use ( $table_name, $tokens, $end ) { + return $this->parse_mysql_upsert_assignment_target( $table_name, $tokens, $assignment_position, $end ); + } + ); + if ( null === $assignment_ranges ) { + return null; + } + + foreach ( $assignment_ranges as $assignment_range ) { + $target = $assignment_range['target']; + $target_column = $target['column']; + $target_key = strtolower( $target_column ); + if ( ! isset( $table_column_lookup[ $target_key ] ) ) { + return null; + } + + $assignment_effects['assigned_columns'][ $target_key ] = $target_column; + + $value_plan = $this->get_mysql_upsert_assignment_value_plan( + $table_name, + $target_column, + $table_column_lookup[ $target_key ], + $tokens, + $assignment_range['value_start'], + $assignment_range['value_end'], + $scope, + $values_column_lookup, + $source_aliases, + $table_column_lookup + ); + if ( null === $value_plan ) { + return null; + } + + if ( isset( $value_plan['last_insert_id_column'] ) ) { + if ( + isset( $assignment_effects['last_insert_id_column'] ) + && 0 !== strcasecmp( (string) $assignment_effects['last_insert_id_column'], $value_plan['last_insert_id_column'] ) + ) { + return null; + } + + $assignment_effects['last_insert_id_column'] = $value_plan['last_insert_id_column']; + } + + $assignments[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + $value_plan['sql'] + ); + } + $position = $end; + return count( $assignments ) > 0 ? $assignments : null; + } + private function get_mysql_upsert_assignment_value_plan( string $table_name, string $target_column, array $target_metadata, array $tokens, int $start, int $end, array $scope, array $values_column_lookup, array $source_aliases, array $table_column_lookup ): ?array { + if ( $this->is_mysql_default_value_token_sequence( $tokens, $start, $end ) ) { + return array( 'sql' => $this->get_mysql_dml_default_assignment_sql_for_column( $target_metadata ) ); + } + + $default_column = $this->parse_mysql_upsert_column_function( $tokens, $start, $end, WP_MySQL_Lexer::DEFAULT_SYMBOL, $table_column_lookup ); + if ( is_array( $default_column ) && $default_column['end'] === $end ) { + $value_sql = $this->get_mysql_dml_default_assignment_sql_for_column( $default_column['lookup_value'] ); + } else { + $value_sql = null; + } + if ( null === $value_sql ) { + $source_column = $this->get_mysql_upsert_values_assignment_source_column( $tokens, $start, $end, $values_column_lookup, $source_aliases ); + $value_sql = null === $source_column ? null : $this->get_postgresql_upsert_excluded_column_sql( $source_column ); + } + if ( null !== $value_sql ) { + return array( 'sql' => $value_sql ); + } + + $last_insert_id_assignment = $this->get_mysql_upsert_last_insert_id_assignment( $table_name, $target_column, $tokens, $start, $end, $scope, $table_column_lookup ); + if ( false === $last_insert_id_assignment ) { + return null; + } + if ( is_array( $last_insert_id_assignment ) ) { + return array( + 'sql' => $last_insert_id_assignment['sql'], + 'last_insert_id_column' => $last_insert_id_assignment['column'], + ); + } + + $scalar_subquery_sql = null; + $value_replacements = $this->get_mysql_upsert_values_expression_replacements( $tokens, $start, $end, $values_column_lookup, $source_aliases ); + $default_replacements = null === $value_replacements ? null : $this->get_mysql_upsert_default_expression_replacements( $tokens, $start, $end, $table_column_lookup ); + $subquery_replacements = null === $default_replacements ? null : $this->get_mysql_upsert_scalar_subquery_expression_replacements( $tokens, $start, $end, $scope ); + $protected_replacements = null === $subquery_replacements ? null : array_merge( $value_replacements, $default_replacements, $subquery_replacements ); + $target_column_replacements = null === $protected_replacements ? null : $this->get_mysql_upsert_target_column_expression_replacements( $table_name, $tokens, $start, $end, $scope, $table_column_lookup, $protected_replacements ); + $expression_replacements = null === $target_column_replacements ? null : array_merge( $protected_replacements, $target_column_replacements ); + + if ( null !== $expression_replacements ) { + $this->sort_mysql_replacements( $expression_replacements ); + } + if ( is_array( $subquery_replacements ) && 1 === count( $subquery_replacements ) && $start === $subquery_replacements[0]['start'] && $end === $subquery_replacements[0]['end'] ) { + $scalar_subquery_sql = $subquery_replacements[0]['sql']; + $expression_replacements = array(); + } + if ( + null === $expression_replacements + || ! $this->is_supported_simple_mysql_upsert_expression_fragment( $tokens, $start, $end, $expression_replacements ) + || $this->contains_unsupported_mysql_convert_function_outside_replacements( $tokens, $start, $end, $expression_replacements ) + || $this->contains_unsupported_mysql_common_function_outside_replacements( $tokens, $start, $end, $expression_replacements ) + || ! $this->mysql_upsert_expression_column_references_resolve_to_scope( $tokens, $start, $end, $expression_replacements, $scope ) + ) { + $scalar_subquery_sql = $this->get_mysql_upsert_scalar_subquery_assignment_sql( $tokens, $start, $end, $scope ); + if ( null === $scalar_subquery_sql ) { + return null; + } + $expression_replacements = array(); + } + + $value_sql = $this->get_mysql_dml_target_value_sql( $target_metadata, $tokens, $start, $end, $scope, $expression_replacements, null, $scalar_subquery_sql ); + return null === $value_sql ? null : array( 'sql' => $value_sql ); + } + private function get_mysql_upsert_last_insert_id_assignment( string $table_name, string $target_column, array $tokens, int $start, int $end, array $scope, array $table_column_lookup ) { + $argument = $this->get_mysql_last_insert_id_argument_range( $tokens, $start, $end ); + if ( null === $argument ) { + return $this->contains_mysql_last_insert_id_function_call( $tokens, $start, $end ) ? false : null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $argument['start'], $argument['end'] ); + if ( + null === $reference + || $reference['end'] !== $argument['end'] + || 0 !== strcasecmp( $reference['column'], $target_column ) + || null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + ) { + return false; + } + + if ( + null !== $reference['qualifier'] + && ! $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table_name, null ) + ) { + return false; + } + + $column_key = strtolower( $reference['column'] ); + if ( + ! isset( $table_column_lookup[ $column_key ] ) + || ! $this->is_mysql_auto_increment_column_metadata( $table_column_lookup[ $column_key ] ) + ) { + return false; + } + return array( + 'column' => (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ), + 'sql' => $this->get_postgresql_dml_column_reference_sql( + (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ), + $table_name + ), + ); + } + private function contains_mysql_last_insert_id_function_call( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null !== $bounds && 'last_insert_id' === $bounds['function'] ) { + return true; + } + } + return false; + } + private function parse_mysql_upsert_assignment_target( string $table_name, array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 >= $end || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) { + return array( + 'column' => $first_identifier, + 'end' => $position + 1, + ); + } + + $second_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $second_identifier ) { + return null; + } + + if ( $position + 4 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 3 ]->id ?? null ) ) { + $third_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 4 ] ?? null ); + if ( + null === $third_identifier + || 0 !== strcasecmp( $first_identifier, $this->main_db_name ) + || ! $this->is_mysql_dml_table_qualifier( $second_identifier, $table_name, null ) + ) { + return null; + } + return array( + 'column' => $third_identifier, + 'end' => $position + 5, + ); + } + + if ( ! $this->is_mysql_dml_table_qualifier( $first_identifier, $table_name, null ) ) { + return null; + } + return array( + 'column' => $second_identifier, + 'end' => $position + 3, + ); + } + private function get_mysql_dml_default_assignment_sql_for_column( array $column_metadata ): string { + $default_sql = $this->get_mysql_dml_default_sql_from_metadata( $column_metadata ); + if ( null !== $default_sql ) { + return $default_sql; + } + return 'NULL'; + } + private function get_mysql_upsert_scalar_subquery_assignment_sql( array $tokens, int $start, int $end, array $scope ): ?string { + $subquery = $this->parse_mysql_upsert_scalar_subquery_assignment( $tokens, $start, $end ); + if ( null === $subquery ) { + return null; + } + + $from_sql = ''; + $subquery_scope = $scope; + if ( null === $subquery['from_position'] || $subquery['from_dual'] ) { + $projection_sql = $this->get_mysql_upsert_simple_scalar_subquery_projection_sql( + $tokens, + $subquery['projection']['start'], + $subquery['projection']['end'], + $scope + ); + } else { + $reference_position = $subquery['from_position'] + 1; + $reference = $this->parse_mysql_main_database_table_reference( $tokens, $reference_position, $subquery['source_end'] ); + if ( null === $reference || $reference_position !== $subquery['source_end'] ) { + return null; + } + + $subquery_scope = $this->get_mysql_single_table_scope( $reference['table'], $reference['alias'] ); + $projection_sql = $this->get_mysql_upsert_table_scalar_subquery_projection_sql( + $tokens, + $subquery['projection']['start'], + $subquery['projection']['end'], + $reference['table'], + $reference['alias'], + $subquery_scope + ); + $from_sql = ' FROM ' . $this->get_postgresql_dml_table_reference_sql( $reference['table'], $reference['alias'] ); + } + + $tail = null === $projection_sql ? null : $this->get_mysql_upsert_scalar_subquery_tail_sql( + $tokens, + $subquery['where_position'], + $subquery['order_position'], + $subquery['limit_position'], + $subquery['select_end'], + $subquery_scope + ); + if ( null === $tail ) { + return null; + } + return sprintf( + '(SELECT %s%s%s%s%s)', + $projection_sql, + $from_sql, + $tail['where'], + $tail['order'], + $tail['limit'] + ); + } + private function parse_mysql_upsert_scalar_subquery_assignment( array $tokens, int $start, int $end ): ?array { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( + null === $after_subquery + || $after_subquery !== $end + || WP_MySQL_Lexer::SELECT_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $projection_start = $start + 2; + $select_end = $end - 1; + if ( + $projection_start >= $select_end + || $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $select_end ); + $projection_end = $from_position ?? $select_end; + $projection = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $projection_end ); + if ( null === $projection || 1 !== count( $projection ) ) { + return null; + } + + $result = array( + 'projection' => $projection[0], + 'from_position' => $from_position, + 'from_dual' => false, + 'where_position' => null, + 'order_position' => null, + 'limit_position' => null, + 'select_end' => $select_end, + 'source_end' => $select_end, + ); + if ( null === $from_position ) { + return $result; + } + + $result['limit_position'] = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $from_position + 1, $select_end ); + $result['order_position'] = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $from_position + 1, + null === $result['limit_position'] ? $select_end : $result['limit_position'] + ); + if ( + null !== $result['limit_position'] + && null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $result['limit_position'] + 1, $select_end ) + ) { + return null; + } + + $tail_start = $result['order_position'] ?? $result['limit_position'] ?? $select_end; + $result['where_position'] = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $tail_start ); + $result['source_end'] = $result['where_position'] ?? $tail_start; + $result['from_dual'] = $from_position + 2 === $result['source_end'] && WP_MySQL_Lexer::DUAL_SYMBOL === ( $tokens[ $from_position + 1 ]->id ?? null ); + return $result; + } + private function get_mysql_upsert_simple_scalar_subquery_projection_sql( array $tokens, int $start, int $end, array $scope ): ?string { + if ( ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $start, $end ) ) { + return null; + } + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $start, + $end, + $scope + ); + return $expression_sql['sql']; + } + private function get_mysql_upsert_scalar_subquery_tail_sql( array $tokens, ?int $where_position, ?int $order_position, ?int $limit_position, int $select_end, array $scope ): ?array { + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $select_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + || ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $where_position + 1, $where_end, $scope ) + ) { + return null; + } + + $translated_where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $where_sql = ' WHERE ' . $translated_where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $select_end; + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope, + true + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $select_end ) ) { + return null; + } + + $limit_sql = $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $select_end ); + } + return array( + 'where' => $where_sql, + 'order' => $order_sql, + 'limit' => $limit_sql, + ); + } + private function get_mysql_upsert_table_scalar_subquery_projection_sql( array $tokens, int $start, int $end, string $table_name, ?string $alias, array $scope ): ?string { + $count_sql = $this->get_mysql_upsert_count_subquery_projection_sql( $tokens, $start, $end, $table_name, $alias ); + if ( null !== $count_sql ) { + return $count_sql; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] !== $end + || null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + ) { + return null; + } + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ); + } + private function get_mysql_upsert_count_subquery_projection_sql( array $tokens, int $start, int $end, string $table_name, ?string $alias ): ?string { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::COUNT_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $after_count = $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ); + if ( null === $after_count || $after_count !== $end ) { + return null; + } + + $argument_start = $start + 2; + $argument_end = $after_count - 1; + if ( + $argument_start + 1 === $argument_end + && WP_MySQL_Lexer::MULT_OPERATOR === ( $tokens[ $argument_start ]->id ?? null ) + ) { + return 'COUNT(*)'; + } + + if ( $this->is_supported_mysql_upsert_literal_select_expression( $tokens, $argument_start, $argument_end ) ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $argument_start, + $argument_end, + array() + ); + return sprintf( 'COUNT(%s)', $expression_sql['sql'] ); + } + + $reference = $this->parse_mysql_column_reference( $tokens, $argument_start, $argument_end ); + if ( null === $reference || $reference['end'] !== $argument_end ) { + return null; + } + + if ( null === $this->get_mysql_table_column_type( 'public', $table_name, $reference['column'] ) ) { + return null; + } + + $qualifier = null; + if ( null !== $reference['qualifier'] ) { + if ( null !== $alias ) { + if ( 0 !== strcasecmp( $reference['qualifier'], $alias ) ) { + return null; + } + + $qualifier = $alias; + } else { + if ( 0 !== strcasecmp( $reference['qualifier'], $table_name ) ) { + return null; + } + + $qualifier = $table_name; + } + } + return sprintf( + 'COUNT(%s)', + $this->get_postgresql_dml_column_reference_sql( $reference['column'], $qualifier ) + ); + } + private function get_mysql_upsert_values_column_lookup( array $column_lookup, array $table_column_lookup ): array { + $values_column_lookup = array(); + foreach ( $column_lookup as $column_key => $column_name ) { + $values_column_lookup[ strtolower( (string) $column_key ) ] = is_string( $column_name ) + ? $column_name + : (string) $column_key; + } + + foreach ( $table_column_lookup as $column_key => $column_metadata ) { + if ( isset( $values_column_lookup[ strtolower( (string) $column_key ) ] ) ) { + continue; + } + + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $values_column_lookup[ strtolower( $column_name ) ] = $column_name; + } + return $values_column_lookup; + } + private function get_mysql_upsert_values_assignment_source_column( array $tokens, int $start, int $end, array $column_lookup, array $source_aliases = array() ): ?string { + $source_column = $this->parse_mysql_upsert_column_function( $tokens, $start, $end, WP_MySQL_Lexer::VALUES_SYMBOL, $column_lookup ); + if ( is_array( $source_column ) && $end === $source_column['end'] ) { + return $source_column['column']; + } + if ( null === $source_column && $start + 4 === $end ) { + return null; + } + + $alias_assignment_reference = $this->get_mysql_upsert_alias_expression_reference( $tokens, $start, $start, $end, $source_aliases ); + if ( ! is_array( $alias_assignment_reference ) || $alias_assignment_reference['end'] !== $end ) { + return null; + } + return $alias_assignment_reference['column']; + } + private function get_postgresql_upsert_excluded_column_sql( string $column_name ): string { + return sprintf( 'excluded.%s', $this->connection->quote_identifier( $column_name ) ); + } + private function get_mysql_upsert_values_expression_replacements( array $tokens, int $start, int $end, array $column_lookup, array $source_aliases = array() ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::VALUES_SYMBOL === $tokens[ $position ]->id ) { + $source_column = $this->parse_mysql_upsert_column_function( $tokens, $position, $end, WP_MySQL_Lexer::VALUES_SYMBOL, $column_lookup ); + if ( ! is_array( $source_column ) ) { + return null; + } + + $replacements[] = $this->get_mysql_upsert_expression_replacement( $position, $source_column['end'], $this->get_postgresql_upsert_excluded_column_sql( $source_column['column'] ) ); + $position = $source_column['end'] - 1; + continue; + } + + $alias_reference = $this->get_mysql_upsert_alias_expression_reference( $tokens, $position, $start, $end, $source_aliases ); + if ( null === $alias_reference ) { + continue; + } + + if ( false === $alias_reference ) { + return null; + } + + $replacements[] = $this->get_mysql_upsert_expression_replacement( $position, $alias_reference['end'], $this->get_postgresql_upsert_excluded_column_sql( $alias_reference['column'] ) ); + $position = $alias_reference['end'] - 1; + } + return $replacements; + } + private function get_mysql_upsert_default_expression_replacements( array $tokens, int $start, int $end, array $table_column_lookup ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + $default_column = $this->parse_mysql_upsert_column_function( $tokens, $position, $end, WP_MySQL_Lexer::DEFAULT_SYMBOL, $table_column_lookup ); + if ( ! is_array( $default_column ) ) { + return null; + } + + $replacements[] = $this->get_mysql_upsert_expression_replacement( $position, $default_column['end'], $this->get_mysql_dml_default_assignment_sql_for_column( $default_column['lookup_value'] ) ); + $position = $default_column['end'] - 1; + } + return $replacements; + } + private function parse_mysql_upsert_column_function( array $tokens, int $position, int $end, int $function_token_id, array $column_lookup ) { + if ( + $position + 4 > $end + || ( $tokens[ $position ]->id ?? null ) !== $function_token_id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== ( $tokens[ $position + 3 ]->id ?? null ) + ) { + return false; + } + + $column_name = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + $column_key = null === $column_name ? null : strtolower( $column_name ); + if ( null === $column_key || ! isset( $column_lookup[ $column_key ] ) ) { + return null; + } + + $lookup_value = $column_lookup[ $column_key ]; + return array( + 'column' => is_string( $lookup_value ) ? $lookup_value : $column_name, + 'end' => $position + 4, + 'lookup_value' => $lookup_value, + ); + } + private function get_mysql_upsert_scalar_subquery_expression_replacements( array $tokens, int $start, int $end, array $scope ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_subquery ) { + return null; + } + + $sql = $this->get_mysql_upsert_scalar_subquery_assignment_sql( $tokens, $position, $after_subquery, $scope ); + if ( null === $sql ) { + return null; + } + + $replacements[] = $this->get_mysql_upsert_expression_replacement( $position, $after_subquery, $sql ); + $position = $after_subquery - 1; + } + return $replacements; + } + private function get_mysql_upsert_target_column_expression_replacements( string $table_name, array $tokens, int $start, int $end, array $scope, array $table_column_lookup, array $protected_ranges ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + $skip_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null === $skip_end ) { + if ( + $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) + || null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) + ) { + continue; + } + $skip_end = $this->get_mysql_temporal_cast_or_convert_expression_end( $tokens, $position, $end ); + } + if ( null !== $skip_end ) { + $position = $skip_end - 1; + continue; + } + + if ( + null === $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + ) { + continue; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + continue; + } + + if ( + null !== $reference['qualifier'] + && ! $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table_name, null ) + ) { + return null; + } + + $column_key = strtolower( $reference['column'] ); + if ( + null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + || ! isset( $table_column_lookup[ $column_key ] ) + ) { + return null; + } + + $column_name = (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ); + $replacements[] = $this->get_mysql_upsert_expression_replacement( $reference['start'], $reference['end'], $this->get_postgresql_dml_column_reference_sql( $column_name, $table_name ) ); + $position = $reference['end'] - 1; + } + return $replacements; + } + private function get_mysql_upsert_expression_replacement( int $start, int $end, string $sql ): array { + return array( + 'start' => $start, + 'end' => $end, + 'sql' => $sql, + ); + } + private function contains_unsupported_mysql_convert_function_outside_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + return $this->contains_unsupported_mysql_function_outside_replacements( $tokens, $start, $end, $replacements, 'contains_unsupported_mysql_convert_function' ); + } + private function contains_unsupported_mysql_common_function_outside_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + return $this->contains_unsupported_mysql_function_outside_replacements( $tokens, $start, $end, $replacements, 'contains_unsupported_mysql_common_function' ); + } + private function contains_unsupported_mysql_function_outside_replacements( array $tokens, int $start, int $end, array $replacements, string $scanner_name ): bool { + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && $this->$scanner_name( $tokens, $segment_start, $replacement['start'] ) + ) { + return true; + } + + $segment_start = max( $segment_start, $replacement['end'] ); + } + return $segment_start < $end + && $this->$scanner_name( $tokens, $segment_start, $end ); + } + private function translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( array $tokens, int $start, int $end, array $replacements ): ?string { + if ( empty( $replacements ) ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + } + + $this->sort_mysql_replacements( $replacements ); + + $temporal_arithmetic_sql = $this->translate_mysql_upsert_temporal_arithmetic_expression_with_replacements_to_postgresql( + $tokens, + $start, + $end, + $replacements + ); + if ( null !== $temporal_arithmetic_sql ) { + return $temporal_arithmetic_sql; + } + + $chunks = array(); + $position = $start; + while ( $position < $end ) { + $replacement = $this->get_mysql_token_sequence_replacement_at_position( $replacements, $position ); + if ( null !== $replacement ) { + $chunks[] = $replacement['sql']; + $position = $replacement['end']; + continue; + } + + $function = $this->translate_mysql_common_function_with_replacements_to_postgresql( $tokens, $position, $end, $replacements ); + if ( false === $function ) { + return null; + } + if ( is_array( $function ) ) { + $chunks[] = $function['sql']; + $position = $function['position'] + 1; + continue; + } + + $segment_end = $end; + foreach ( $replacements as $candidate ) { + if ( $candidate['start'] > $position ) { + $segment_end = min( $segment_end, $candidate['start'] ); + break; + } + } + + for ( $scan = $position; $scan < $segment_end; $scan++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $scan, $end ); + if ( + null !== $bounds + && $this->mysql_token_sequence_replacements_intersect_range( $replacements, $scan, $bounds['close'] + 1 ) + ) { + $segment_end = $scan; + break; + } + } + + if ( $segment_end <= $position ) { + return null; + } + + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $segment_end ); + $position = $segment_end; + } + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + private function translate_mysql_upsert_temporal_arithmetic_expression_with_replacements_to_postgresql( array $tokens, int $start, int $end, array $replacements ): ?string { + $date_arithmetic = $this->get_mysql_date_arithmetic_function_bounds( $tokens, $start, $end ); + if ( null !== $date_arithmetic && $date_arithmetic['close'] + 1 === $end ) { + return $this->get_postgresql_mysql_temporal_arithmetic_sql_with_replacements( + $tokens, + $date_arithmetic, + $replacements + ); + } + + $infix_arithmetic = $this->get_mysql_infix_interval_expression_bounds( $tokens, $start, $end ); + if ( null !== $infix_arithmetic && $infix_arithmetic['close'] + 1 === $end ) { + return $this->get_postgresql_mysql_temporal_arithmetic_sql_with_replacements( + $tokens, + $infix_arithmetic, + $replacements + ); + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( + null === $bounds + || $bounds['close'] + 1 !== $end + || 'timestampadd' !== $bounds['function'] + ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 3 !== count( $arguments ) ) { + return null; + } + + $interval = $this->get_mysql_timestampadd_interval( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + $arguments[1]['start'], + $arguments[1]['end'] + ); + if ( null === $interval ) { + return null; + } + + $interval_sql = $interval['sql'] ?? null; + if ( null === $interval_sql ) { + $value_sql = $this->translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( $tokens, $arguments[1]['start'], $arguments[1]['end'], $replacements ); + if ( null === $value_sql ) { + return null; + } + + $interval_sql = $this->get_postgresql_mysql_interval_sql( $value_sql, $interval['unit'] ); + } + + $datetime_sql = $this->translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( $tokens, $arguments[2]['start'], $arguments[2]['end'], $replacements ); + if ( null === $datetime_sql ) { + return null; + } + return sprintf( + '(%1$s + %2$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $datetime_sql ), + $interval_sql + ); + } + private function get_postgresql_mysql_temporal_arithmetic_sql_with_replacements( array $tokens, array $bounds, array $replacements ): ?string { + $expression_sql = $this->translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( $tokens, $bounds['expression_start'], $bounds['expression_end'], $replacements ); + if ( null === $expression_sql ) { + return null; + } + + $interval_sql = $bounds['interval_sql'] ?? null; + if ( null === $interval_sql ) { + $value_sql = $this->translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( $tokens, $bounds['interval_value_start'], $bounds['interval_value_end'], $replacements ); + if ( null === $value_sql ) { + return null; + } + + $interval_sql = $this->get_postgresql_mysql_interval_sql( $value_sql, $bounds['interval_unit'] ); + } + return sprintf( + '(%1$s %2$s %3$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $bounds['operator'], + $interval_sql + ); + } + private function translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( array $tokens, int $start, int $end, array $replacements ): ?string { + return $this->translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( + $tokens, + $start, + $end, + $this->get_mysql_token_sequence_replacements_for_range( $replacements, $start, $end ) + ); + } + private function translate_mysql_common_function_with_replacements_to_postgresql( array $tokens, int $position, int $end, array $replacements ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $function_end = $bounds['close'] + 1; + if ( ! $this->mysql_token_sequence_replacements_intersect_range( $replacements, $position, $function_end ) ) { + return null; + } + + if ( in_array( $bounds['function'], array( 'last_insert_id', 'timestampadd', 'timestampdiff' ), true ) ) { + return false; + } + + return $this->get_postgresql_mysql_common_function_translation( $tokens, $position, $end, $replacements, $bounds ) ?? false; + } + private function get_mysql_token_sequence_replacement_at_position( array $replacements, int $position ): ?array { + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] === $position ) { + return $replacement; + } + } + return null; + } + private function get_mysql_token_sequence_replacements_for_range( array $replacements, int $start, int $end ): array { + $ranged_replacements = array(); + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] >= $start && $replacement['end'] <= $end ) { + $ranged_replacements[] = $replacement; + } + } + return $ranged_replacements; + } + private function mysql_token_sequence_replacements_intersect_range( array $replacements, int $start, int $end ): bool { + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] < $end && $replacement['end'] > $start ) { + return true; + } + } + return false; + } + private function get_mysql_upsert_alias_expression_reference( array $tokens, int $position, int $start, int $end, array $source_aliases ) { + if ( empty( $source_aliases ) || ! isset( $tokens[ $position ] ) ) { + return null; + } + + $identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifier_key = strtolower( $identifier ); + if ( ( $source_aliases['row'] ?? null ) === $identifier_key ) { + if ( + $position + 2 >= $end + || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) + ) { + return false; + } + + $source_column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $source_column ) { + return false; + } + + $source_key = strtolower( $source_column ); + if ( ! isset( $source_aliases['qualified'][ $source_key ] ) ) { + return false; + } + return array( + 'column' => $source_aliases['qualified'][ $source_key ], + 'end' => $position + 3, + ); + } + + if ( + isset( $source_aliases['unqualified'][ $identifier_key ] ) + && ( $position <= $start || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position - 1 ]->id ?? null ) ) + && ( $position + 1 >= $end || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) + && ( $position + 1 >= $end || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) + ) { + return array( + 'column' => $source_aliases['unqualified'][ $identifier_key ], + 'end' => $position + 1, + ); + } + return null; + } + private function is_supported_simple_mysql_upsert_expression_fragment( array $tokens, int $start, int $end, array $replacements ): bool { + if ( ! empty( $replacements ) ) { + $this->sort_mysql_replacements( $replacements ); + + for ( $position = $start; $position < $end; ) { + $temporal_arithmetic_sql = $this->translate_mysql_upsert_temporal_arithmetic_expression_with_replacements_to_postgresql( + $tokens, + $position, + $end, + $replacements + ); + if ( null !== $temporal_arithmetic_sql ) { + $position = $end; + continue; + } + + $date_arithmetic = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $position, $end ); + if ( null !== $date_arithmetic ) { + $position = $date_arithmetic['position'] + 1; + continue; + } + + $infix_interval = $this->translate_mysql_infix_interval_expression_to_postgresql( $tokens, $position, $end ); + if ( null !== $infix_interval ) { + $position = $infix_interval['position'] + 1; + continue; + } + + $replacement = $this->get_mysql_token_sequence_replacement_at_position( $replacements, $position ); + if ( null !== $replacement ) { + $position = $replacement['end']; + continue; + } + + $function = $this->translate_mysql_common_function_with_replacements_to_postgresql( $tokens, $position, $end, $replacements ); + if ( false === $function ) { + return false; + } + if ( is_array( $function ) ) { + $position = $function['position'] + 1; + continue; + } + + $segment_end = $end; + foreach ( $replacements as $candidate ) { + if ( $candidate['start'] > $position ) { + $segment_end = min( $segment_end, $candidate['start'] ); + break; + } + } + + if ( $segment_end <= $position || ! $this->is_supported_simple_mysql_upsert_expression_segment( $tokens, $position, $segment_end ) ) { + return false; + } + + $position = $segment_end; + } + return true; + } + + return $this->is_supported_simple_mysql_upsert_expression_segment( $tokens, $start, $end ); + } + private function mysql_upsert_expression_column_references_resolve_to_scope( array $tokens, int $start, int $end, array $replacements, array $scope ): bool { + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $segment_start, $replacement['start'], $scope ) + ) { + return false; + } + + $segment_start = $replacement['end']; + } + return $segment_start >= $end + || $this->mysql_expression_column_references_resolve_to_scope( $tokens, $segment_start, $end, $scope ); + } + private function is_supported_simple_mysql_upsert_expression_segment( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $date_arithmetic = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $position, $end ); + if ( null !== $date_arithmetic ) { + $position = $date_arithmetic['position']; + continue; + } + + $infix_interval = $this->translate_mysql_infix_interval_expression_to_postgresql( $tokens, $position, $end ); + if ( null !== $infix_interval ) { + $position = $infix_interval['position']; + continue; + } + + $temporal_cast_end = $this->get_mysql_temporal_cast_or_convert_expression_end( $tokens, $position, $end ); + if ( null !== $temporal_cast_end ) { + $position = $temporal_cast_end - 1; + continue; + } + + $common_function = $this->translate_mysql_common_function_to_postgresql( $tokens, $position, $end ); + if ( null !== $common_function ) { + $position = $common_function['position']; + continue; + } + + if ( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $position ]->id ) { + continue; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $position ] ) ) { + return false; + } + } + return true; + } + private function is_supported_simple_select_projection( array $tokens, int $start, int $end ): bool { + if ( $start + 1 === $end && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start ]->id ) { + return true; + } + + if ( $this->is_supported_simple_select_count_projection( $tokens, $start, $end ) ) { + return true; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || array() === $ranges ) { + return false; + } + + foreach ( $ranges as $range ) { + $reference = $this->parse_mysql_column_reference( $tokens, $range['start'], $range['end'] ); + if ( null === $reference || $reference['end'] !== $range['end'] ) { + return false; + } + } + return true; + } + 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 + || ( + WP_MySQL_Lexer::MULT_OPERATOR !== $tokens[ $start + 2 ]->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 ] ); + } + 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 ) ) { + $count_argument_sql = WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start + 2 ]->id + ? '*' + : $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ); + $sql = sprintf( + 'COUNT(%s)', + $count_argument_sql + ); + + 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 ); + } + 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 + ); + } + private function get_mysql_select_statement_contextual_replacements( + array $tokens, + int $projection_start, + int $statement_end + ): ?array { + $clauses = $this->get_mysql_select_clause_positions( $tokens, $projection_start, $statement_end, false ); + $where_position = $clauses['where_position']; + $having_position = $clauses['having_position']; + $order_position = $clauses['order_position']; + if ( null === $where_position && null === $having_position && null === $order_position ) { + return null; + } + + $first_clause_position = min( array_filter( array( $where_position, $having_position, $order_position ), 'is_int' ) ); + $from_position = $clauses['from_position']; + if ( null === $from_position || $from_position >= $first_clause_position ) { + return null; + } + + $from_end = $clauses['from_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; + } + + $group_position_before_having = null === $having_position || null === $clauses['group_position'] || $clauses['group_position'] > $having_position + ? null + : $clauses['group_position']; + + if ( null !== $where_position ) { + $where_end = $clauses['where_end'] ?? $statement_end; + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + if ( null === $having_position || null !== $group_position_before_having ) { + $this->append_changed_select_replacement( $replacements, $where_sql, $where_position + 1, $where_end ); + } + } + + if ( null !== $having_position && null === $group_position_before_having ) { + $having_end = $clauses['having_end'] ?? $statement_end; + $having_replacements = $this->get_mysql_having_without_group_by_contextual_replacements( + $tokens, + $projection_start, + $from_position, + $having_position, + $having_end, + $where_position, + $scope + ); + if ( null !== $having_replacements ) { + $replacements = array_merge( $replacements, $having_replacements ); + } + } + + if ( + $this->is_valid_mysql_select_order_by_clause( + $tokens, + $order_position, + $clauses['order_end'] ?? $statement_end + ) + ) { + $order_end = $clauses['order_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 ) ) + ); + $this->append_changed_select_replacement( $replacements, $order_sql, $order_position + 2, $order_end ); + } + + $replacements = array_merge( + $replacements, + $this->get_mysql_select_implicit_order_contextual_replacements( + $tokens, + $projection_start, + $from_position, + $from_end, + $where_position, + $order_position, + $statement_end + ) + ); + return $replacements; + } + private function append_changed_select_replacement( array &$replacements, array $translation, int $start, int $end ): void { + if ( $translation['changed'] ) { + $replacements[] = array( + 'start' => $start, + 'end' => $end, + 'sql' => $translation['sql'], + ); + } + } + private function get_mysql_select_implicit_order_contextual_replacements( + array $tokens, + int $projection_start, + int $from_position, + int $from_end, + ?int $where_position, + ?int $order_position, + int $statement_end + ): array { + if ( $this->is_wordpress_available_post_mime_types_select_shape( $tokens, $projection_start, $statement_end ) ) { + return array( + array( + 'start' => $projection_start, + 'end' => $projection_start + 1, + 'sql' => '', + ), + array( + 'start' => $statement_end, + 'end' => $statement_end, + 'sql' => 'GROUP BY post_mime_type ORDER BY MIN("ID") ASC', + ), + ); + } + + if ( + null !== $order_position + || null === $where_position + || ! $this->is_wordpress_term_cache_priming_select_shape( + $tokens, + $projection_start, + $from_position, + $from_end, + $where_position, + $statement_end + ) + ) { + return array(); + } + return array( + array( + 'start' => $statement_end, + 'end' => $statement_end, + 'sql' => 'ORDER BY tt.term_taxonomy_id ASC', + ), + ); + } + private function is_wordpress_available_post_mime_types_select_shape( array $tokens, int $projection_start, int $statement_end ): bool { + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $projection_start + 3 ] ?? null ); + return $projection_start + 12 === $statement_end + && isset( $tokens[ $projection_start + 11 ] ) + && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $projection_start + 1 ], 'post_mime_type' ) + && WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $projection_start + 2 ]->id + && null !== $table_name + && $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $projection_start + 4 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $projection_start + 5 ], 'post_type' ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $projection_start + 6 ]->id + && $this->is_mysql_string_literal_token( $tokens[ $projection_start + 7 ] ) + && WP_MySQL_Lexer::AND_SYMBOL === $tokens[ $projection_start + 8 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $projection_start + 9 ], 'post_mime_type' ) + && WP_MySQL_Lexer::NOT_EQUAL_OPERATOR === $tokens[ $projection_start + 10 ]->id + && $this->is_mysql_string_literal_token( $tokens[ $projection_start + 11 ] ) + && '' === $tokens[ $projection_start + 11 ]->get_value(); + } + private function is_wordpress_term_cache_priming_select_shape( + array $tokens, + int $projection_start, + int $from_position, + int $from_end, + int $where_position, + int $statement_end + ): bool { + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $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' + ) + || $from_end !== $where_position + || ! $this->is_mysql_distinct_term_taxonomy_from_shape( $tokens, $from_position, $from_end ) + ) { + return false; + } + + $where_bounds = $this->normalize_mysql_expression_bounds( $tokens, $where_position + 1, $statement_end ); + $where_reference = $this->parse_mysql_column_reference( $tokens, $where_bounds['start'], $where_bounds['end'] ); + if ( + null === $where_reference + || 't' !== strtolower( (string) $where_reference['qualifier'] ) + || 'term_id' !== strtolower( $where_reference['column'] ) + || ! isset( $tokens[ $where_reference['end'] ], $tokens[ $where_reference['end'] + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $where_reference['end'] ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $where_reference['end'] + 1 ]->id + ) { + return false; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( + $tokens, + $where_reference['end'] + 1, + $where_bounds['end'] + ); + if ( $after_close !== $where_bounds['end'] ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $where_reference['end'] + 2, $where_bounds['end'] - 1 ); + if ( null === $items || empty( $items ) ) { + return false; + } + + foreach ( $items as $item ) { + if ( + $item['start'] + 1 !== $item['end'] + || ! isset( $tokens[ $item['start'] ] ) + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $item['start'] ] ) + ) { + return false; + } + } + return true; + } + private function get_mysql_having_without_group_by_contextual_replacements( + array $tokens, + int $projection_start, + int $from_position, + int $having_position, + int $having_end, + ?int $where_position, + array $scope + ): ?array { + if ( + $having_position + 1 >= $having_end + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $projection_start, $having_position ) + || $this->contains_mysql_aggregate_call( $tokens, $projection_start, $from_position ) + || $this->contains_mysql_aggregate_call( $tokens, $having_position + 1, $having_end ) + ) { + return null; + } + + $having_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $having_position + 1, + $having_end, + $scope + ); + + if ( null === $where_position ) { + return array( + array( + 'start' => $having_position, + 'end' => $having_end, + 'sql' => 'WHERE ' . $having_sql['sql'], + ), + ); + } + + if ( $where_position > $having_position ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $having_position, + $scope + ); + return array( + array( + 'start' => $where_position + 1, + 'end' => $having_position, + 'sql' => sprintf( + '(%s) AND (%s)', + $where_sql['sql'], + $having_sql['sql'] + ), + ), + array( + 'start' => $having_position, + 'end' => $having_end, + 'sql' => '', + ), + ); + } + 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; + } + 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'] ) + ) + ); + } + private function translate_mysql_token_sequence_with_replacements_to_postgresql( + array $tokens, + int $start, + int $end, + array $replacements, + bool $only_range_replacements = false + ): string { + $chunks = array(); + $position = $start; + + foreach ( $replacements as $replacement ) { + if ( $only_range_replacements && ( $replacement['start'] < $start || $replacement['end'] > $end ) ) { + continue; + } + + 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' ) ); + } + private function get_mysql_token_range_sql( string $query, array $tokens, int $start, int $end ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) ) { + return null; + } + + $range_start = $tokens[ $start ]->start; + $last_token = $tokens[ $end - 1 ]; + $range_end = $last_token->start + $last_token->length; + return substr( $query, $range_start, $range_end - $range_start ); + } + 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 = $allow_wordpress_posts_id_tiebreaker + ? $this->get_wordpress_posts_order_id_tiebreaker_sql( $tokens, $order_items, $scope, true ) + : null; + 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, + ); + } + private function get_simple_wordpress_approved_comments_order_tiebreaker_sql( + array $tokens, + string $table_name, + ?int $where_position, + ?int $where_end, + array $order_items + ): ?string { + if ( + ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) + || null === $where_position + || null === $where_end + ) { + return null; + } + + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $where_position + 1, $where_end ); + if ( null === $conjuncts ) { + return null; + } + + $required = array( + 'comment_post_id' => false, + 'comment_approved' => false, + ); + foreach ( $conjuncts as $conjunct ) { + $equality = $this->get_mysql_top_level_column_literal_equality( + $tokens, + $conjunct['start'], + $conjunct['end'], + 'comments' + ); + if ( null === $equality ) { + continue; + } + + if ( 'comment_post_id' === $equality['column'] ) { + $required['comment_post_id'] = true; + } elseif ( 'comment_approved' === $equality['column'] && '1' === $equality['literal'] ) { + $required['comment_approved'] = true; + } + } + + if ( + in_array( false, $required, true ) + || 1 !== count( $order_items ) + || 'ASC' !== $order_items[0]['direction'] + || ! $this->is_mysql_column_reference_expression( + $tokens, + $order_items[0]['expression_start'], + $order_items[0]['expression_end'], + 'comment_date_gmt', + 'comments', + true + ) + ) { + return null; + } + return $this->connection->quote_identifier( 'comment_ID' ) . ' ASC'; + } + private function get_mysql_top_level_column_literal_equality( array $tokens, int $start, int $end, string $table_base ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $equal_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $bounds['start'], $bounds['end'] ); + if ( + null === $equal_position + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $equal_position + 1, $bounds['end'] ) + ) { + return null; + } + + foreach ( array( array( $bounds['start'], $equal_position, $equal_position + 1, $bounds['end'] ), array( $equal_position + 1, $bounds['end'], $bounds['start'], $equal_position ) ) as $side ) { + $reference = $this->parse_mysql_column_reference( $tokens, $side[0], $side[1] ); + if ( + null === $reference + || $reference['end'] !== $side[1] + || ( null !== $reference['qualifier'] && ! $this->is_mysql_wordpress_table_name( $reference['qualifier'], $table_base ) ) + ) { + continue; + } + + $literal_bounds = $this->normalize_mysql_expression_bounds( $tokens, $side[2], $side[3] ); + $literal = null; + if ( $this->is_mysql_string_literal_range( $tokens, $literal_bounds['start'], $literal_bounds['end'] ) ) { + $literal = $tokens[ $literal_bounds['start'] ]->get_value(); + } else { + $numeric = $this->parse_mysql_numeric_literal( $tokens, $literal_bounds['start'], $literal_bounds['end'] ); + $literal = null !== $numeric && $numeric['end'] === $literal_bounds['end'] ? 'literal' : null; + } + if ( null !== $literal ) { + return array( + 'column' => strtolower( $reference['column'] ), + 'literal' => $literal, + ); + } + } + return null; + } + private function get_wordpress_posts_order_id_tiebreaker_sql( array $tokens, array $order_items, array $scope, bool $include_menu_order_title ): ?string { + if ( ! empty( $scope['unknown'] ) || 1 !== count( $scope['tables'] ) ) { + return null; + } + + $descriptors = array( + array( array( 'post_date' ), 'DESC', 'DESC' ), + ); + if ( $include_menu_order_title ) { + $descriptors[] = array( array( 'menu_order', 'post_title' ), 'ASC', 'ASC' ); + } + + foreach ( $descriptors as $descriptor ) { + if ( count( $order_items ) !== count( $descriptor[0] ) ) { + continue; + } + + $references = array(); + $matched_table = null; + foreach ( $descriptor[0] as $index => $expected_column ) { + $order_item = $order_items[ $index ]; + $reference = $this->parse_mysql_column_reference( $tokens, $order_item['expression_start'], $order_item['expression_end'] ); + if ( $descriptor[1] !== $order_item['direction'] || null === $reference || $reference['end'] !== $order_item['expression_end'] || strtolower( $reference['column'] ) !== $expected_column ) { + continue 2; + } + + $table = $this->get_mysql_single_scope_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_table_name( $table['table'], 'posts' ) || ( null !== $matched_table && $matched_table !== $table ) ) { + continue 2; + } + + $matched_table = $table; + $references[] = $reference; + } + return $this->get_mysql_order_id_tiebreaker_sql( $tokens, $references, 'ID', $descriptor[2] ); + } + return null; + } + private function get_mysql_order_id_tiebreaker_sql( array $tokens, array $references, string $id_column, string $direction ): string { + foreach ( $references as $reference ) { + if ( null !== $reference['qualifier'] ) { + return sprintf( + '%s.%s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['start'] + 1 ), + $this->connection->quote_identifier( $id_column ), + $direction + ); + } + } + return $this->connection->quote_identifier( $id_column ) . ' ' . $direction; + } + 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, + ); + } + 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->get_mysql_case_insensitive_wordpress_text_column_reference( $tokens, $bounds['start'], $bounds['end'], $scope, true ); + if ( null === $reference ) { + return null; + } + return $this->get_mysql_key_value_array( 'sql', 'LOWER(' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) . ')', 'position', $bounds['end'] - 1 ); + } + private function translate_mysql_wordpress_text_expression_predicate_to_postgresql( + array $tokens, + int $position, + int $start, + int $end, + array $scope + ): ?array { + $left_boundaries = array( WP_MySQL_Lexer::AND_SYMBOL, WP_MySQL_Lexer::OR_SYMBOL, WP_MySQL_Lexer::WHEN_SYMBOL, WP_MySQL_Lexer::XOR_SYMBOL ); + $previous_token_id = $tokens[ $position - 1 ]->id ?? null; + if ( + $position <= $start + || ( + ! in_array( $previous_token_id, $left_boundaries, true ) + && ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $previous_token_id + || $position - 1 <= $start + || ! in_array( $tokens[ $position - 2 ]->id ?? null, $left_boundaries, true ) + ) + ) + ) { + return null; + } + + $reference = $this->get_mysql_case_insensitive_wordpress_text_column_reference( $tokens, $position, $end, $scope, false ); + return null === $reference ? null : $this->translate_mysql_wordpress_text_column_predicate_to_postgresql( $tokens, $reference, $reference['end'], $end, true ); + } + private function translate_mysql_predicate_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope, + array $replacements = array() + ): array { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + continue; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $translated_subquery = $this->translate_mysql_parenthesized_select_predicate_to_postgresql( + $tokens, + $position, + $after_subquery, + $scope + ); + if ( null !== $translated_subquery ) { + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $segment_start, $position, $replacements, true ); + } + + $chunks[] = $translated_subquery['sql']; + $segment_start = $translated_subquery['position'] + 1; + $position = $translated_subquery['position']; + $changed = true; + continue; + } + + $position = $after_subquery - 1; + continue; + } + } + + $translated_predicate = $this->translate_mysql_integer_column_string_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null === $translated_predicate ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $segment_start, $position, $replacements, true ); + } + + $chunks[] = $translated_predicate['sql']; + $segment_start = $translated_predicate['position'] + 1; + $position = $translated_predicate['position']; + $changed = true; + } + + if ( ! $changed ) { + return array( + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $start, $end, $replacements, true ), + 'changed' => ! empty( $replacements ), + ); + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $segment_start, $end, $replacements, true ); + } + return array( + 'sql' => implode( ' ', array_filter( $chunks, 'strlen' ) ), + 'changed' => true, + ); + } + private function translate_mysql_integer_column_string_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + foreach ( + array( + 'translate_mysql_numeric_literal_truthiness_predicate_to_postgresql', + 'translate_mysql_decimal_cast_like_predicate_to_postgresql', + ) as $translator + ) { + $translated = $this->$translator( $tokens, $position, $end ); + if ( null !== $translated ) { + return $translated; + } + } + + foreach ( + array( + 'translate_mysql_temporal_expression_column_comparison_to_postgresql', + 'translate_mysql_wordpress_text_predicate_to_postgresql', + 'translate_mysql_integer_column_string_in_predicate_to_postgresql', + 'translate_mysql_integer_column_string_comparison_to_postgresql', + 'translate_mysql_text_column_numeric_comparison_to_postgresql', + ) as $translator + ) { + $translated = $this->$translator( $tokens, $position, $end, $scope ); + if ( null !== $translated ) { + return $translated; + } + } + return $this->translate_mysql_metadata_column_reference_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + } + private function translate_mysql_temporal_expression_column_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $temporal_expression = $this->parse_mysql_temporal_comparison_expression( $tokens, $position, $end ); + if ( + null !== $temporal_expression + && isset( $tokens[ $temporal_expression['end'] ] ) + && $this->is_mysql_comparison_operator_token( $tokens[ $temporal_expression['end'] ] ) + ) { + $reference = $this->parse_mysql_column_reference( $tokens, $temporal_expression['end'] + 1, $end ); + $column_type = null === $reference ? null : $this->get_mysql_temporal_column_type_for_reference( $reference, $scope ); + if ( + null !== $reference + && null !== $column_type + && $this->is_mysql_temporal_comparison_predicate_boundary( $tokens, $reference['end'], $end ) + ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_temporal_expression_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $temporal_expression['start'], $temporal_expression['end'] ), + $temporal_expression['returns_timestamp'] + ), + $tokens[ $temporal_expression['end'] ]->get_bytes(), + $this->get_postgresql_mysql_temporal_column_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $column_type + ) + ), + 'position' => $reference['end'] - 1, + ); + } + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null === $reference + || ! isset( $tokens[ $reference['end'] ] ) + || ! $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + ) { + return null; + } + + $column_type = $this->get_mysql_temporal_column_type_for_reference( $reference, $scope ); + if ( null === $column_type ) { + return null; + } + + $temporal_expression = $this->parse_mysql_temporal_comparison_expression( $tokens, $reference['end'] + 1, $end ); + if ( + null === $temporal_expression + || ! $this->is_mysql_temporal_comparison_predicate_boundary( $tokens, $temporal_expression['end'], $end ) + ) { + return null; + } + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_temporal_column_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $column_type + ), + $tokens[ $reference['end'] ]->get_bytes(), + $this->get_postgresql_mysql_temporal_expression_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $temporal_expression['start'], $temporal_expression['end'] ), + $temporal_expression['returns_timestamp'] + ) + ), + 'position' => $temporal_expression['end'] - 1, + ); + } + private function parse_mysql_temporal_comparison_expression( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close || $after_close > $end ) { + return null; + } + + $inner = $this->parse_mysql_temporal_comparison_expression( $tokens, $position + 1, $after_close - 1 ); + if ( null === $inner || $inner['start'] !== $position + 1 || $inner['end'] !== $after_close - 1 ) { + return null; + } + return array( + 'start' => $position, + 'end' => $after_close, + 'returns_timestamp' => $inner['returns_timestamp'], + ); + } + + $date_arithmetic = $this->get_mysql_date_arithmetic_function_bounds( $tokens, $position, $end ); + if ( null !== $date_arithmetic ) { + return array( + 'start' => $position, + 'end' => $date_arithmetic['close'] + 1, + 'returns_timestamp' => true, + ); + } + + $common_function = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( + null !== $common_function + && in_array( $common_function['function'], self::MYSQL_TEMPORAL_COMPARISON_COMMON_FUNCTION_NAMES, true ) + && null !== $this->translate_mysql_common_function_to_postgresql( $tokens, $position, $end ) + ) { + return array( + 'start' => $position, + 'end' => $common_function['close'] + 1, + 'returns_timestamp' => 'timestampadd' === $common_function['function'], + ); + } + + $nonparenthesized_function = $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ); + if ( + null !== $nonparenthesized_function + && in_array( strtolower( $tokens[ $position ]->get_value() ), self::MYSQL_TEMPORAL_COMPARISON_NONPARENTHESIZED_FUNCTION_NAMES, true ) + ) { + return array( + 'start' => $position, + 'end' => $position + 1, + 'returns_timestamp' => false, + ); + } + return null; + } + private function is_mysql_temporal_comparison_predicate_boundary( array $tokens, int $position, int $end ): bool { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return true; + } + return $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $position ]->id ) + || in_array( $tokens[ $position ]->id, self::MYSQL_TEMPORAL_COMPARISON_PREDICATE_BOUNDARY_TOKENS, true ); + } + private function get_mysql_temporal_column_type_for_reference( array $reference, array $scope ): ?string { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + if ( null === $column_type ) { + return null; + } + return in_array( $this->get_base_mysql_dml_column_type( $column_type ), self::MYSQL_TEMPORAL_COLUMN_BASE_TYPES, true ) ? $column_type : null; + } + private function get_postgresql_mysql_temporal_expression_comparison_text_sql( string $expression_sql, bool $returns_timestamp ): string { + if ( $returns_timestamp ) { + return sprintf( + 'TO_CHAR(%s, %s)', + $expression_sql, + $this->connection->quote( 'YYYY-MM-DD HH24:MI:SS' ) + ); + } + return $this->get_postgresql_mysql_temporal_text_comparison_sql( $expression_sql ); + } + private function get_postgresql_mysql_temporal_column_comparison_text_sql( string $column_sql, string $column_type ): string { + if ( 'date' !== $this->get_base_mysql_dml_column_type( $column_type ) ) { + return sprintf( 'CAST(%s AS text)', $column_sql ); + } + return $this->get_postgresql_mysql_temporal_text_comparison_sql( $column_sql ); + } + private function get_postgresql_mysql_temporal_text_comparison_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %1\$s = '' THEN '' WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %1\$s || ' 00:00:00' ELSE %1\$s END", + $expression_text_sql + ); + } + 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; + } + private function translate_mysql_wordpress_text_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->get_mysql_case_insensitive_wordpress_text_column_reference( $tokens, $position, $end, $scope, false ); + if ( null !== $reference ) { + $translated = $this->translate_mysql_wordpress_text_column_predicate_to_postgresql( $tokens, $reference, $reference['end'], $end, false ); + if ( null !== $translated ) { + return $translated; + } + } + + 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->get_mysql_case_insensitive_wordpress_text_column_reference( $tokens, $position + 2, $end, $scope, false ); + if ( null === $reference ) { + return null; + } + return $this->get_mysql_key_value_array( 'sql', $this->get_postgresql_wordpress_text_lower_binary_predicate_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 ); + } + private function translate_mysql_wordpress_text_column_predicate_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end, + bool $like_only + ): ?array { + $predicate = $this->get_mysql_wordpress_text_predicate_descriptor( $tokens, $operator_position, $end, $like_only ); + if ( null === $predicate ) { + return null; + } + $column_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ); + $sql = isset( $predicate['right_sql'] ) + ? $this->get_postgresql_wordpress_text_lower_binary_predicate_sql( $column_sql, $predicate['operator'], $predicate['right_sql'] ) + : 'LOWER(' . $column_sql . ')' . $predicate['operator_sql']; + return $this->get_mysql_key_value_array( 'sql', $sql, 'position', $predicate['position'] ); + } + private function get_mysql_wordpress_text_predicate_descriptor( + array $tokens, + int $operator_position, + int $end, + bool $like_only + ): ?array { + if ( ! isset( $tokens[ $operator_position ] ) ) { + return null; + } + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && in_array( $tokens[ $operator_position + 1 ]->id, array( WP_MySQL_Lexer::LIKE_SYMBOL, WP_MySQL_Lexer::IN_SYMBOL ), true ) + ) { + $not_sql = ' NOT'; + ++$operator_position; + } + + if ( WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_position ]->id ) { + $pattern = $this->get_mysql_string_like_pattern_sql( $tokens, $operator_position + 1, $end ); + return null === $pattern ? null : $this->get_mysql_key_value_array( 'operator_sql', $not_sql . ' LIKE LOWER(' . $pattern['pattern_sql'] . ')' . $pattern['escape_sql'], 'position', $pattern['end'] - 1 ); + } + + if ( $like_only ) { + return null; + } + + if ( WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $operator_position ]->id ) { + if ( + '' === $not_sql + && isset( $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 $this->get_mysql_key_value_array( 'operator', $tokens[ $operator_position ]->get_bytes(), 'right_sql', $this->translate_mysql_token_to_postgresql( $tokens[ $operator_position + 1 ] ), 'position', $operator_position + 1 ); + } + return null; + } + + if ( ! isset( $tokens[ $operator_position + 1 ] ) || $operator_position + 1 >= $end || 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 $this->get_mysql_key_value_array( 'operator_sql', $not_sql . ' IN (' . implode( ', ', $item_sql ) . ')', 'position', $after_close - 1 ); + } + 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 ] ); + $pattern_end = $position + 1; + + if ( ! isset( $tokens[ $pattern_end ] ) || WP_MySQL_Lexer::ESCAPE_SYMBOL !== $tokens[ $pattern_end ]->id ) { + return $this->get_mysql_key_value_array( 'pattern_sql', $pattern_sql, 'escape_sql', $this->get_mysql_no_backslash_like_escape_sql(), 'end', $pattern_end ); + } + if ( ! isset( $tokens[ $pattern_end + 1 ] ) || $pattern_end + 1 >= $end || ! $this->is_mysql_string_literal_token( $tokens[ $pattern_end + 1 ] ) ) { + return null; + } + return $this->get_mysql_key_value_array( 'pattern_sql', $pattern_sql, 'escape_sql', ' ESCAPE ' . $this->translate_mysql_token_to_postgresql( $tokens[ $pattern_end + 1 ] ), 'end', $pattern_end + 2 ); + } + private function get_mysql_no_backslash_like_escape_sql(): string { + return $this->is_sql_mode_active( 'NO_BACKSLASH_ESCAPES' ) ? " ESCAPE ''" : ''; + } + private function get_postgresql_wordpress_text_lower_binary_predicate_sql( string $left_sql, string $operator, string $right_sql ): string { + return sprintf( 'LOWER(%s) %s LOWER(%s)', $left_sql, $operator, $right_sql ); + } + private function get_mysql_case_insensitive_wordpress_text_column_reference( array $tokens, int $start, int $end, array $scope, bool $require_end ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + return null !== $reference && ( ! $require_end || $reference['end'] === $end ) && $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) ? $reference : null; + } + private function is_mysql_case_insensitive_wordpress_text_column_reference( array $reference, array $scope ): bool { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + $table = $scope['aliases'][ $alias ] ?? null; + if ( null === $table || ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return false; + } + } else { + if ( ! empty( $scope['unknown'] ) ) { + return false; + } + + $table = null; + foreach ( $scope['tables'] as $scope_table ) { + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $scope_table['schema'], $scope_table['table'] ) + ) { + return false; + } + + if ( null === $this->get_cached_mysql_table_column_type( $scope_table['schema'], $scope_table['table'], $reference['column'] ) ) { + continue; + } + + if ( null !== $table ) { + return false; + } + + $table = $scope_table; + } + } + if ( null === $table ) { + return false; + } + + $column_name = strtolower( $reference['column'] ); + $column_matches = false; + foreach ( self::MYSQL_CASE_INSENSITIVE_WORDPRESS_TEXT_COLUMNS as $descriptor ) { + $space = strpos( $descriptor, ' ' ); + $table_base = substr( $descriptor, 0, $space ); + $columns = substr( $descriptor, $space ); + if ( $this->is_mysql_wordpress_table_name( $table['table'], $table_base ) ) { + $column_matches = false !== strpos( $columns, ' ' . $column_name . ' ' ); + break; + } + } + if ( + ! $column_matches + || ( $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_metadata_for_reference( $reference, $scope, 'get_cached_mysql_table_column_collation' ); + return null !== $collation && 1 === preg_match( '/(^|_)ci($|_)/', strtolower( trim( $collation ) ) ); + } + private function get_mysql_single_scope_table_for_column_reference( array $reference, array $scope ): ?array { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + $table = $scope['aliases'][ $alias ] ?? null; + if ( null === $table || ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + return $table; + } + + if ( ! empty( $scope['unknown'] ) || 1 !== count( $scope['tables'] ) ) { + return null; + } + return $scope['tables'][0]; + } + 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 ); + } + 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, + ); + } + 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; + } + 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, + ); + } + 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, self::MYSQL_BOOLEAN_PREDICATE_RIGHT_BOUNDARY_TOKENS, true ); + } + 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; + } + if ( null === $and_position || ! 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; + } + private function is_mysql_boolean_predicate_left_boundary_token_id( ?int $token_id ): bool { + return in_array( $token_id, self::MYSQL_BOOLEAN_PREDICATE_LEFT_BOUNDARY_TOKENS, true ); + } + private function translate_mysql_decimal_cast_like_predicate_to_postgresql( + array $tokens, + int $position, + int $end + ): ?array { + $cast_bounds = $this->get_mysql_typed_cast_bounds( $tokens, $position, $end, array( 'decimal' ) ); + 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 = $operator_position + 2; + if ( ! isset( $tokens[ $operator_position + 1 ] ) || $operator_position + 1 >= $end ) { + return null; + } + + $has_explicit_escape = isset( $tokens[ $operator_position + 2 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $operator_position + 2 ]->id; + if ( $has_explicit_escape && isset( $tokens[ $operator_position + 3 ] ) && $operator_position + 3 < $end ) { + $pattern_end += 2; + } + return array( + 'sql' => sprintf( + 'CAST(%s AS text)%s LIKE %s%s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $cast_bounds['close'] + 1 ), + $not_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $operator_position + 1, $pattern_end ), + $has_explicit_escape ? '' : $this->get_mysql_no_backslash_like_escape_sql() + ), + 'position' => $pattern_end - 1, + ); + } + 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; + } + + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + $resolved_column = $this->get_cached_mysql_table_column_name( $table['schema'], $table['table'], $reference['column'] ); + 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, + ); + } + private function get_mysql_table_column_name( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $metadata = $this->get_mysql_table_catalog_column_metadata_row( $table_schema, $table_name, $column_name ); + if ( null !== $metadata && array_key_exists( 'column_name', $metadata ) ) { + return (string) $metadata['column_name']; + } + + $exact_metadata = $this->get_mysql_table_catalog_column_metadata_rows( + $table_schema, + $table_name, + $column_name, + true + ); + return 1 === count( $exact_metadata ) && array_key_exists( 'column_name', $exact_metadata[0] ) + ? (string) $exact_metadata[0]['column_name'] + : null; + } + private function get_cached_mysql_table_column_name( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $metadata = $this->get_cached_mysql_table_catalog_column_metadata_row( $table_schema, $table_name, $column_name ); + if ( null !== $metadata && array_key_exists( 'column_name', $metadata ) ) { + return (string) $metadata['column_name']; + } + + $exact_metadata = $this->get_cached_mysql_table_catalog_column_metadata_rows( + $table_schema, + $table_name, + $column_name, + true + ); + return 1 === count( $exact_metadata ) && array_key_exists( 'column_name', $exact_metadata[0] ) + ? (string) $exact_metadata[0]['column_name'] + : null; + } + private function translate_mysql_identifier_value_to_postgresql( string $identifier ): string { + return strtolower( $identifier ) !== $identifier + ? $this->connection->quote_identifier( $identifier ) + : $identifier; + } + 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, + ); + } + 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, + ); + } + 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, + ); + } + 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, + ); + } + private function get_mysql_single_table_scope( + string $table_name, + ?string $alias = null, + string $schema = 'public' + ): array { + $resolved_schema = 'public' === $schema + ? $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ) + : $this->resolve_mysql_table_schema_for_introspection( $schema, $table_name ); + $table = array( + 'schema' => $resolved_schema, + 'table' => $table_name, + ); + return array( + 'tables' => array( $table ), + 'aliases' => array( + strtolower( null === $alias ? $table_name : $alias ) => $table, + ), + ); + } + private function get_mysql_table_reference_backend_schema( array $reference ): string { + if ( empty( $reference['schema_qualified'] ) && 'public' === $reference['schema'] ) { + return $this->get_mysql_unqualified_dml_table_backend_schema( $reference['table'] ); + } + return $this->resolve_mysql_table_schema_for_introspection( $reference['schema'], $reference['table'] ); + } + private function mysql_scope_references_non_public_schema( array $scope ): bool { + foreach ( $scope['tables'] ?? array() as $table ) { + if ( 'public' !== ( $table['schema'] ?? 'public' ) ) { + return true; + } + } + return false; + } + private function translate_mysql_table_reference_range_to_postgresql( array $tokens, int $start, int $end ): ?string { + $chunks = array(); + $segment_start = $start; + $position = $start; + $expect_next = true; + + while ( $position < $end ) { + if ( $expect_next ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = $this->get_postgresql_table_reference_sql( $reference ); + $position = $reference['position']; + $segment_start = $position; + $expect_next = false; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_next = true; + } + + ++$position; + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ); + } + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + private function get_postgresql_table_reference_sql( array $reference ): string { + $sql = $this->get_postgresql_table_identifier_sql( + $this->get_mysql_table_reference_backend_schema( $reference ), + $reference['table'] + ); + + if ( null !== $reference['alias'] ) { + $sql .= ' AS ' . $this->connection->quote_identifier( $reference['alias'] ); + } + return $sql; + } + 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->get_mysql_table_reference_backend_schema( $reference ), + '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; + } + private function parse_mysql_main_database_table_name( array $tokens, int &$position ): ?string { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, true ); + 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, true ); + if ( null === $table_name || 0 !== strcasecmp( $first_identifier, $this->main_db_name ) ) { + return null; + } + + $position += 2; + return $table_name; + } + private function parse_mysql_main_database_table_reference( array $tokens, int &$position, int $end ): ?array { + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $alias = null; + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + ++$position; + } + } + return array( + 'table' => $table_name, + 'alias' => $alias, + ); + } + private function get_postgresql_dml_table_reference_sql( string $table_name, ?string $alias ): string { + $sql = $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ); + if ( null !== $alias ) { + $sql .= ' AS ' . $this->connection->quote_identifier( $alias ); + } + return $sql; + } + private function get_postgresql_dml_ctid_reference_sql( ?string $alias ): string { + if ( null === $alias ) { + return 'ctid'; + } + return $this->connection->quote_identifier( $alias ) . '.ctid'; + } + private function get_postgresql_dml_column_reference_sql( string $column, ?string $alias ): string { + $column_sql = $this->connection->quote_identifier( $column ); + if ( null === $alias ) { + return $column_sql; + } + return $this->connection->quote_identifier( $alias ) . '.' . $column_sql; + } + private function is_mysql_dml_table_qualifier( string $qualifier, string $table_name, ?string $alias ): bool { + return 0 === strcasecmp( $qualifier, $table_name ) + || ( null !== $alias && 0 === strcasecmp( $qualifier, $alias ) ); + } + 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 + ) { + if ( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $start + 2 ]->id ) { + return $this->connection->quote_identifier( $tokens[ $start + 2 ]->get_value() ); + } + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ); + } + + if ( isset( $tokens[ $start ] ) && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $start ]->id ) { + $table_name = $tokens[ $start ]->get_value(); + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + if ( 'public' !== $table_schema ) { + return $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + } + return $this->connection->quote_identifier( $table_name ); + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null, true ); + if ( null !== $table_name ) { + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + if ( 'public' !== $table_schema ) { + return $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + } + } + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start ] ?? null ); + } + 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; + $schema_qualified = false; + ++$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; + $schema_qualified = true; + $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, + 'schema_qualified' => $schema_qualified, + ); + } + 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; + } + 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; + } + private function parse_mysql_column_reference( array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column ) { + return null; + } + return array( + 'start' => $position, + 'end' => $position + 3, + 'qualifier' => $first_identifier, + 'column' => $column, + ); + } + return array( + 'start' => $position, + 'end' => $position + 1, + 'qualifier' => null, + 'column' => $first_identifier, + ); + } + private function is_mysql_integer_column_reference( array $reference, array $scope, bool $use_cached_metadata = true ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope, $use_cached_metadata ); + return null !== $column_type && $this->is_mysql_integer_family_column_type( $column_type ); + } + private function is_mysql_text_family_column_reference( array $reference, array $scope, bool $use_cached_metadata = true ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope, $use_cached_metadata ); + return null !== $column_type && $this->is_mysql_text_family_column_type( $column_type ); + } + 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 + ); + } + private function get_mysql_column_type_for_reference( array $reference, array $scope, bool $use_cached_metadata = true ): ?string { + return $this->get_mysql_column_metadata_for_reference( + $reference, + $scope, + $use_cached_metadata ? 'get_cached_mysql_table_column_type' : 'get_mysql_table_column_type' + ); + } + private function get_mysql_column_metadata_for_reference( array $reference, array $scope, string $metadata_method ): ?string { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + return $this->$metadata_method( $table['schema'], $table['table'], $reference['column'] ); + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_value = null; + foreach ( $scope['tables'] as $table ) { + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + $value = $this->$metadata_method( $table['schema'], $table['table'], $reference['column'] ); + if ( null === $value ) { + continue; + } + + if ( null !== $matched_value ) { + return null; + } + + $matched_value = $value; + } + return $matched_value; + } + 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 ] ); + } + private function is_mysql_zero_literal_range( array $tokens, int $start, int $end ): bool { + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return 1 === preg_match( '/^[[:space:]]*[+]?0+[[:space:]]*$/', $tokens[ $start ]->get_value() ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + return null !== $literal + && $literal['start'] === $start + && $literal['end'] === $end + && $this->is_mysql_zero_numeric_literal_range( $tokens, $start, $end ); + } + 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; + } + private function is_mysql_zero_numeric_literal_range( array $tokens, int $start, int $end ): bool { + $position = $this->get_mysql_numeric_literal_range_token_position( $tokens, $start, $end ); + return null !== $position + && $this->is_mysql_numeric_literal_token( $tokens[ $position ] ) + && 0.0 === (float) $tokens[ $position ]->get_value(); + } + private function is_mysql_integer_numeric_literal_range( array $tokens, int $start, int $end ): bool { + $position = $this->get_mysql_numeric_literal_range_token_position( $tokens, $start, $end ); + return null !== $position + && $this->is_mysql_unsigned_integer_token( $tokens[ $position ] ); + } + private function get_mysql_numeric_literal_range_token_position( array $tokens, int $start, int $end ): ?int { + if ( ! isset( $tokens[ $start ] ) ) { + return null; + } + + 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 ? $start : null; + } + private function is_mysql_numeric_literal_token( WP_MySQL_Token $token ): bool { + return in_array( $token->id, self::MYSQL_NUMERIC_LITERAL_TOKENS, true ); + } + 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; + } + 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 + ); + } + private function is_supported_simple_mysql_expression_fragment( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + $date_arithmetic = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $i, $end ); + if ( null !== $date_arithmetic ) { + $i = $date_arithmetic['position']; + continue; + } + + $infix_interval = $this->translate_mysql_infix_interval_expression_to_postgresql( $tokens, $i, $end ); + if ( null !== $infix_interval ) { + $i = $infix_interval['position']; + continue; + } + + $temporal_cast_end = $this->get_mysql_temporal_cast_or_convert_expression_end( $tokens, $i, $end ); + if ( null !== $temporal_cast_end ) { + $i = $temporal_cast_end - 1; + continue; + } + + $common_function = $this->translate_mysql_common_function_to_postgresql( $tokens, $i, $end ); + if ( null !== $common_function ) { + $i = $common_function['position']; + continue; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $i ] ) ) { + return false; + } + } + return true; + } + private function is_supported_simple_mysql_expression_fragment_with_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + if ( empty( $replacements ) ) { + return $this->is_supported_simple_mysql_expression_fragment( $tokens, $start, $end ); + } + + for ( $i = $start; $i < $end; $i++ ) { + $replacement_end = $this->get_covering_mysql_replacement_range_end( $i, $replacements ); + if ( null !== $replacement_end ) { + $i = $replacement_end - 1; + continue; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $i ] ) ) { + return false; + } + } + return true; + } + private function mysql_expression_column_references_resolve_to_scope( array $tokens, int $start, int $end, array $scope ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $reference = $this->scan_mysql_expression_column_reference( $tokens, $position, $start, $end ); + if ( null === $reference ) { + continue; + } + + if ( null === $this->get_mysql_column_type_for_reference( $reference, $scope ) ) { + return false; + } + } + return true; + } + private function mysql_expression_qualified_references_resolve_to_scope( array $tokens, int $start, int $end, array $scope ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $reference = $this->scan_mysql_expression_column_reference( $tokens, $position, $start, $end ); + if ( null === $reference ) { + continue; + } + + if ( + null !== $reference['qualifier'] + && ! isset( $scope['aliases'][ strtolower( $reference['qualifier'] ) ] ) + ) { + return false; + } + } + return true; + } + private function scan_mysql_expression_column_reference( array $tokens, int &$position, int $start, int $end ): ?array { + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + return null; + } + + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) ) { + return null; + } + + $temporal_cast_end = $this->get_mysql_temporal_cast_or_convert_expression_end( $tokens, $position, $end ); + if ( null !== $temporal_cast_end ) { + $position = $temporal_cast_end - 1; + return null; + } + + if ( null === $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null !== $reference ) { + $position = $reference['end'] - 1; + } + return $reference; + } + private function get_mysql_temporal_cast_or_convert_expression_end( array $tokens, int $position, int $end ): ?int { + $temporal_cast = $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $position, + $end, + array( + 'cast' => array( 'date_time', 'date' ), + 'convert' => array( 'date' ), + ) + ); + return null === $temporal_cast ? null : $temporal_cast['close'] + 1; + } + private function is_supported_simple_mysql_expression_token( WP_MySQL_Token $token ): bool { + if ( null !== $this->get_mysql_dml_identifier_token_value( $token ) ) { + return true; + } + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CASE_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COALESCE_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::ELSE_SYMBOL, + WP_MySQL_Lexer::END_SYMBOL, + WP_MySQL_Lexer::FALSE_SYMBOL, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::IN_SYMBOL, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::IS_SYMBOL, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::MULT_OPERATOR, + WP_MySQL_Lexer::NOT_SYMBOL, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::NOW_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::SUBSTR_SYMBOL, + WP_MySQL_Lexer::SUBSTRING_SYMBOL, + WP_MySQL_Lexer::THEN_SYMBOL, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + WP_MySQL_Lexer::WHEN_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + private function is_supported_simple_select_order_by_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $reference_end = $end; + if ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $end - 1 ]->id ?? null ) + ) { + --$reference_end; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start + 2, $reference_end ); + return null !== $reference && $reference['end'] === $reference_end; + } + 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 null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $start, $end ); + } + 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() ) ); + } + private function is_nonempty_mysql_order_by_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + return null !== $items && ! empty( $items ); + } + private function translate_mysql_joined_dml_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $scope, bool $require_column_resolution = false ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $item_ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $item_ranges || empty( $item_ranges ) ) { + return null; + } + + $items = array(); + foreach ( $item_ranges as $item_range ) { + $item_start = $item_range['start']; + $item_end = $item_range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( + $item_start >= $item_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $item_start, $item_end ) + || ( + $require_column_resolution + ? ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $item_start, $item_end, $scope ) + : ! $this->mysql_expression_qualified_references_resolve_to_scope( $tokens, $item_start, $item_end, $scope ) + ) + ) { + return null; + } + + $expression = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $item_start, + $item_end, + $scope + ); + $items[] = $expression['sql'] . $direction; + } + return ' ORDER BY ' . implode( ', ', $items ); + } + private function translate_simple_dml_limit_clause_to_postgresql( array $tokens, int $start, int $end, bool $allow_offset_count = false ): ?string { + if ( + WP_MySQL_Lexer::LIMIT_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || ! isset( $tokens[ $start + 1 ] ) + ) { + return null; + } + + if ( $start + 2 === $end && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) ) { + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes(); + } + + if ( $allow_offset_count ) { + $bounds = $this->get_mysql_limit_offset_count_bounds( $tokens, $start, $end ); + if ( null !== $bounds ) { + return ' LIMIT ' . $tokens[ $bounds['count_position'] ]->get_bytes() . ' OFFSET ' . $tokens[ $bounds['offset_position'] ]->get_bytes(); + } + } + + if ( + $allow_offset_count + && $start + 4 === $end + && isset( $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::OFFSET_SYMBOL === $tokens[ $start + 2 ]->id + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 3 ] ) + ) { + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes() . ' OFFSET ' . $tokens[ $start + 3 ]->get_bytes(); + } + return null; + } + 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(); + } + private function translate_mysql_select_row_locking_query( string $query, ?array &$query_context = null ): ?string { + $tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + if ( null === $statement_end ) { + return null; + } + + $locking_start = $this->find_mysql_select_row_locking_clause_start( $tokens, 1, $statement_end ); + if ( null === $locking_start ) { + return null; + } + + $stripped_query = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $locking_start ); + return $this->translate_mysql_select_query_for_postgresql( $stripped_query )['sql']; + } + private function find_mysql_select_row_locking_clause_start( array $tokens, int $start, int $end ): ?int { + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( + WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 1 ] ) + && in_array( $tokens[ $i + 1 ]->id, self::MYSQL_SELECT_ROW_LOCKING_MODE_TOKENS, true ) + ) { + if ( $end === $this->parse_supported_mysql_select_row_locking_clause( $tokens, $i, $end ) ) { + return $i; + } + throw new InvalidArgumentException( 'Unsupported SELECT locking clause.' ); + } + + if ( + WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 1 ] ) + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $i + 1 ]->id + ) { + if ( $end === $this->parse_supported_mysql_select_row_locking_clause( $tokens, $i, $end ) ) { + return $i; + } + throw new InvalidArgumentException( 'Unsupported SELECT locking clause.' ); + } + } + return null; + } + private function parse_supported_mysql_select_row_locking_clause( array $tokens, int $start, int $end ): ?int { + if ( + isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $start ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::SHARE_SYMBOL === $tokens[ $start + 2 ]->id + && WP_MySQL_Lexer::MODE_SYMBOL === $tokens[ $start + 3 ]->id + ) { + return $start + 4; + } + + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[ $start ]->id + || ! in_array( $tokens[ $start + 1 ]->id, self::MYSQL_SELECT_ROW_LOCKING_MODE_TOKENS, true ) + ) { + return null; + } + + $position = $start + 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OF_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_mysql_select_locking_table_reference( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_mysql_select_locking_table_reference( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + } + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::NOWAIT_SYMBOL === $tokens[ $position ]->id ) { + return $position + 1; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::SKIP_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::LOCKED_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return $position + 2; + } + return $position; + } + private function parse_mysql_select_locking_table_reference( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end || null === $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ) ) { + return null; + } + + $position = $start + 1; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ) + ) { + $position += 2; + } + return $position; + } + 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; + } + private function get_mysql_query_context_statement_end_position( array &$query_context, int $position ): ?int { + $cache_key = (string) $position; + if ( ! array_key_exists( $cache_key, $query_context['statement_end_positions'] ) ) { + $query_context['statement_end_positions'][ $cache_key ] = $this->get_mysql_statement_end_position( $query_context['tokens'], $position ); + } + return $query_context['statement_end_positions'][ $cache_key ]; + } + 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; + } + private function find_top_level_mysql_token( array $tokens, int $token_id, int $start, int $end ): ?int { + return $this->find_first_top_level_mysql_token( $tokens, array( $token_id ), $start, $end ); + } + private function find_top_level_mysql_context_token( array $tokens, ?array &$query_context, int $token_id, int $start, int $end ): ?int { + return null === $query_context + ? $this->find_top_level_mysql_token( $tokens, $token_id, $start, $end ) + : $this->find_top_level_mysql_query_context_token( $query_context, $token_id, $start, $end ); + } + private function find_top_level_mysql_query_context_token( array &$query_context, int $token_id, int $start, int $end ): ?int { + return $this->find_first_top_level_mysql_query_context_token( $query_context, array( $token_id ), $start, $end ); + } + 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; + } + private function find_first_top_level_mysql_context_token( array $tokens, ?array &$query_context, array $token_ids, int $start, int $end ): ?int { + return null === $query_context + ? $this->find_first_top_level_mysql_token( $tokens, $token_ids, $start, $end ) + : $this->find_first_top_level_mysql_query_context_token( $query_context, $token_ids, $start, $end ); + } + private function find_first_top_level_mysql_query_context_token( array &$query_context, array $token_ids, int $start, int $end ): ?int { + $index = $this->get_mysql_query_context_top_level_token_index( $query_context, $start, $end ); + $first = null; + foreach ( $token_ids as $token_id ) { + if ( empty( $index[ $token_id ] ) ) { + continue; + } + + $position = $index[ $token_id ][0]; + if ( null === $first || $position < $first ) { + $first = $position; + } + } + return $first; + } + private function get_mysql_query_context_top_level_token_index( array &$query_context, int $start, int $end ): array { + $cache_key = $start . ':' . $end; + if ( array_key_exists( $cache_key, $query_context['top_level_token_indexes'] ) ) { + return $query_context['top_level_token_indexes'][ $cache_key ]; + } + + $tokens = $query_context['tokens']; + $index = array(); + $depth = 0; + for ( $i = $start; $i < $end && isset( $tokens[ $i ] ); $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + break; + } + continue; + } + + if ( 0 === $depth ) { + $index[ $tokens[ $i ]->id ][] = $i; + } + } + + $query_context['top_level_token_indexes'][ $cache_key ] = $index; + return $index; + } + 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; + } + private function contains_top_level_mysql_context_token( array $tokens, ?array &$query_context, int $start, int $end, array $token_ids ): bool { + if ( null === $query_context ) { + return $this->contains_top_level_mysql_token( $tokens, $start, $end, $token_ids ); + } + return $this->contains_top_level_mysql_query_context_token( $query_context, $start, $end, $token_ids ); + } + private function contains_top_level_mysql_query_context_token( array &$query_context, int $start, int $end, array $token_ids ): bool { + $index = $this->get_mysql_query_context_top_level_token_index( $query_context, $start, $end ); + foreach ( $token_ids as $token_id ) { + if ( ! empty( $index[ $token_id ] ) ) { + return true; + } + } + return false; + } + 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; + } + 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++ ) { + $fragment_token_id = $tokens[ $i ]->id; + foreach ( self::MYSQL_TOKEN_SEQUENCE_TRANSLATION_RULES as $translator ) { + $uses_end = ':without_end' !== substr( $translator, -12 ); + $translator = $uses_end ? $translator : substr( $translator, 0, -12 ); + $translated_fragment = $uses_end ? $this->$translator( $tokens, $i, $end ) : $this->$translator( $tokens, $i ); + if ( null !== $translated_fragment ) { + break; + } + } + $append_no_backslash_like_escape = false; + if ( null !== $translated_fragment ) { + $fragment = $translated_fragment['sql']; + $fragment_token_id = $translated_fragment['token_id']; + $i = $translated_fragment['position']; + } elseif ( WP_MySQL_Lexer::AS_SYMBOL === $previous_token_id && $this->is_mysql_string_literal_token( $tokens[ $i ] ) ) { + $fragment = $this->connection->quote_identifier( $tokens[ $i ]->get_value() ); + } else { + $fragment = $this->translate_mysql_token_to_postgresql( $tokens[ $i ], $tokens[ $i + 1 ] ?? null ); + $append_no_backslash_like_escape = true; + } + + if ( '' === $fragment ) { + continue; + } + + if ( + $append_no_backslash_like_escape + && '' !== $this->get_mysql_no_backslash_like_escape_sql() + && isset( $tokens[ $i - 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $i - 1 ]->id + && isset( $tokens[ $i ] ) + && $this->is_mysql_string_literal_token( $tokens[ $i ] ) + && ( + ! isset( $tokens[ $i + 1 ] ) + || $i + 1 >= $end + || WP_MySQL_Lexer::ESCAPE_SYMBOL !== $tokens[ $i + 1 ]->id + ) + ) { + $fragment .= $this->get_mysql_no_backslash_like_escape_sql(); + } + + $sql .= ( '' === $sql || $this->should_join_mysql_tokens_without_space( $previous_token_id, $fragment_token_id ) ? '' : ' ' ) . $fragment; + + $previous_token_id = $fragment_token_id; + } + return $sql; + } + 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 + && ! in_array( + $tokens[ $position + 2 ]->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 + ) + ) { + return null; + } + return array( + 'sql' => '', + 'token_id' => WP_MySQL_Lexer::FROM_SYMBOL, + 'position' => $position + 1, + ); + } + private function translate_mysql_select_row_locking_clause_to_empty_postgresql( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! ( + WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $position ]->id + ) + ) { + return null; + } + + $after_locking = $this->parse_supported_mysql_select_row_locking_clause( $tokens, $position, $end ); + if ( null === $after_locking ) { + return null; + } + return array( + 'sql' => '', + 'token_id' => $tokens[ $position ]->id, + 'position' => $after_locking - 1, + ); + } + 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, + ); + } + 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 ) { + if ( ! isset( $tokens[ $position + 1 ] ) || $position + 1 >= $end ) { + return null; + } + + if ( WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + } elseif ( + 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 + ) + ) { + $position += 3; + } else { + 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 ); + $allow_empty_list = WP_MySQL_Lexer::USE_SYMBOL === $hint_action; + $is_identifier_list = false; + if ( null !== $after_close ) { + $is_identifier_list = $allow_empty_list && $position + 1 === $after_close - 1; + $expect_identifier = true; + for ( $i = $position + 1; $i < $after_close - 1; $i++ ) { + $token = $tokens[ $i ] ?? null; + if ( null === $token ) { + $is_identifier_list = false; + break; + } + + if ( $expect_identifier ) { + $is_identifier_list = WP_MySQL_Lexer::PRIMARY_SYMBOL === $token->id + || null !== $this->get_mysql_identifier_token_value( $token ); + } else { + $is_identifier_list = WP_MySQL_Lexer::COMMA_SYMBOL === $token->id; + } + if ( ! $is_identifier_list ) { + break; + } + + $expect_identifier = ! $expect_identifier; + } + + if ( $i > $position + 1 ) { + $is_identifier_list = $is_identifier_list && ! $expect_identifier; + } + } + if ( null === $after_close || ! $is_identifier_list ) { + return null; + } + return array( + 'end' => $after_close, + ); + } + private function is_mysql_index_hint_marker( array $tokens, int $position, int $end ): bool { + return $position + 1 < $end + && ( + WP_MySQL_Lexer::FORCE_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::IGNORE_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::USE_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) + && ( + WP_MySQL_Lexer::INDEX_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + || WP_MySQL_Lexer::KEY_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + ); + } + 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'], + ); + } + 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, + ); + } + private function translate_mysql_group_concat_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_group_concat_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $parsed = $this->parse_mysql_group_concat_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $parsed ) { + return null; + } + + $expression_sql = $this->get_mysql_group_concat_expression_sql( $tokens, $parsed['expression_ranges'] ); + if ( null === $expression_sql ) { + return null; + } + + $order_sql = ''; + if ( null !== $parsed['order_start'] ) { + $order_sql = $this->get_mysql_group_concat_order_by_sql( + $tokens, + $parsed['order_start'], + $parsed['order_end'], + $parsed['distinct'] ? $parsed['expression_ranges'][0] : null, + $parsed['distinct'] ? $expression_sql : null + ); + if ( null === $order_sql ) { + return null; + } + } + + $aggregate_sql = $this->get_mysql_group_concat_aggregate_sql( $tokens, $parsed, $expression_sql, $order_sql ); + if ( null === $aggregate_sql ) { + return null; + } + return array( + 'sql' => $this->get_mysql_group_concat_max_len_truncation_sql( $aggregate_sql ), + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + private function get_mysql_group_concat_expression_sql( array $tokens, array $expression_ranges ): ?string { + if ( 1 === count( $expression_ranges ) ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_ranges[0]['start'], $expression_ranges[0]['end'] ); + } + $parts = array(); + foreach ( $expression_ranges as $range ) { + $parts[] = sprintf( 'CAST(%s AS text)', $this->translate_mysql_token_sequence_to_postgresql( $tokens, $range['start'], $range['end'] ) ); + } + return implode( ' || ', $parts ); + } + private function get_mysql_group_concat_aggregate_sql( array $tokens, array $parsed, string $expression_sql, string $order_sql ): ?string { + $separator_sql = null === $parsed['separator_start'] ? $this->connection->quote( ',' ) : $this->translate_mysql_token_sequence_to_postgresql( $tokens, $parsed['separator_start'], $parsed['separator_end'] ); + return sprintf( + $parsed['distinct'] ? 'STRING_AGG(DISTINCT CAST(%1$s AS text), CAST(%2$s AS text)%3$s)' : 'STRING_AGG(CAST(%1$s AS text), CAST(%2$s AS text)%3$s)', + $expression_sql, + $separator_sql, + $order_sql + ); + } + private function get_mysql_group_concat_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + return array( + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + private function parse_mysql_group_concat_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $distinct = false; + $expression_start = $start; + if ( WP_MySQL_Lexer::DISTINCT_SYMBOL === ( $tokens[ $expression_start ]->id ?? null ) ) { + $distinct = true; + ++$expression_start; + } + + $separator_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SEPARATOR_SYMBOL, $expression_start, $end ); + if ( + null !== $separator_position + && ( + $separator_position + 1 >= $end + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SEPARATOR_SYMBOL, $separator_position + 1, $end ) + ) + ) { + return null; + } + + $before_separator_end = $separator_position ?? $end; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $expression_start, $before_separator_end ); + if ( + null !== $order_position + && ( + ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $before_separator_end + ) + ) { + return null; + } + $expression_end = $order_position ?? $before_separator_end; + if ( $expression_start >= $expression_end ) { + return null; + } + + $expression_arguments = $this->split_top_level_mysql_arguments( $tokens, $expression_start, $expression_end ); + if ( + null === $expression_arguments + || empty( $expression_arguments ) + || ( $distinct && 1 !== count( $expression_arguments ) ) + ) { + return null; + } + if ( + $distinct + && null !== $separator_position + && ! $this->is_mysql_string_literal_range( $tokens, $separator_position + 1, $end ) + ) { + return null; + } + return array( + 'distinct' => $distinct, + 'expression_ranges' => $expression_arguments, + 'order_start' => null === $order_position ? null : $order_position + 2, + 'order_end' => $before_separator_end, + 'separator_start' => null === $separator_position ? null : $separator_position + 1, + 'separator_end' => $end, + ); + } + private function get_mysql_group_concat_order_by_sql( array $tokens, int $start, int $end, ?array $distinct_expression_range = null, ?string $distinct_expression_sql = null ): ?string { + $items = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( + null === $items + || empty( $items ) + || ( null !== $distinct_expression_range && ( 1 !== count( $items ) || null === $distinct_expression_sql ) ) + ) { + return null; + } + + $distinct_item_sql = null === $distinct_expression_range ? null : $this->translate_mysql_token_sequence_to_postgresql( $tokens, $distinct_expression_range['start'], $distinct_expression_range['end'] ); + $order_sql = array(); + foreach ( $items as $item ) { + $item_start = $item['start']; + $item_end = $item['end']; + $direction = ''; + + if ( isset( $tokens[ $item_end - 1 ] ) ) { + if ( WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' DESC'; + --$item_end; + } elseif ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' ASC'; + --$item_end; + } + } + + if ( $item_start >= $item_end ) { + return null; + } + + $item_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item_start, $item_end ); + if ( null !== $distinct_item_sql ) { + return $distinct_item_sql === $item_sql + ? sprintf( ' ORDER BY CAST(%s AS text)%s', $distinct_expression_sql, $direction ) + : null; + } + + $order_sql[] = $item_sql . $direction; + } + return ' ORDER BY ' . implode( ', ', $order_sql ); + } + private function get_mysql_group_concat_max_len_truncation_sql( string $aggregate_sql ): string { + $value = $this->get_mysql_system_variable_value( 'group_concat_max_len' ) ?? '1024'; + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + $limit = 0; + } else { + $max = (string) self::MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT; + $limit = strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) + ? self::MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT + : (int) $value; + } + + return $this->get_postgresql_mysql_utf8_safe_byte_prefix_sql( $aggregate_sql, $limit ); + } + private function get_postgresql_mysql_utf8_safe_byte_prefix_sql( string $expression_sql, int $limit ): string { + if ( 0 === $limit ) { + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE %2$s END', + $expression_sql, + $this->connection->quote( '' ) + ); + } + + $bytes_sql = sprintf( "CONVERT_TO(CAST(%s AS text), 'UTF8')", $expression_sql ); + $safe_length_sql = (string) $limit; + $next_byte_sql = sprintf( 'GET_BYTE(%s, %d)', $bytes_sql, $limit ); + + $branches = array( + sprintf( + 'WHEN %s THEN %d', + sprintf( '(%1$s < 128 OR %1$s >= 192)', $next_byte_sql ), + $limit + ), + ); + + for ( $offset = 1; $offset <= 3; $offset++ ) { + if ( $limit < $offset ) { + break; + } + + $byte_sql = sprintf( 'GET_BYTE(%s, %d)', $bytes_sql, $limit - $offset ); + $branches[] = sprintf( + 'WHEN %s THEN %d', + sprintf( '(%1$s < 128 OR %1$s >= 192)', $byte_sql ), + $limit - $offset + ); + } + + $safe_length_sql = 'CASE ' . implode( ' ', $branches ) . ' ELSE 0 END'; + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN OCTET_LENGTH(CAST(%1\$s AS text)) <= %2\$d THEN CAST(%1\$s AS text) ELSE CONVERT_FROM(SUBSTRING(%3\$s FROM 1 FOR %4\$s), 'UTF8') END", + $expression_sql, + $limit, + $bytes_sql, + $safe_length_sql + ); + } + 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'], + ); + } + private function translate_mysql_typed_cast_or_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $position, + $end, + array( + 'cast' => array( 'integer', 'character', 'date_time', 'date', 'binary' ), + 'convert' => array( 'integer', 'character', 'binary', 'decimal', 'date' ), + ) + ); + 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_typed_cast_or_convert_sql( $bounds['type'], $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + private function get_postgresql_mysql_typed_cast_or_convert_sql( string $type, string $expression_sql ): string { + if ( 'integer' === $type ) { + return $this->get_postgresql_mysql_integer_cast_sql( $expression_sql ); + } + + if ( 'date_time' === $type ) { + return $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + } + + if ( 'date' === $type ) { + return $this->get_postgresql_mysql_ymd_format_sql( $expression_sql ); + } + + if ( 'decimal' === $type ) { + return $this->get_postgresql_mysql_numeric_cast_sql( $expression_sql ); + } + + if ( in_array( $type, array( 'character', 'binary' ), true ) ) { + return sprintf( 'CAST(%s AS text)', $expression_sql ); + } + throw new InvalidArgumentException( 'Unsupported MySQL CAST/CONVERT type.' ); + } + 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 + ); + } + 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 ) + ); + } + private function get_mysql_typed_cast_bounds( array $tokens, int $position, int $end, array $supported_types ): ?array { + return $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $position, + $end, + array( + 'cast' => $supported_types, + ) + ); + } + private function get_mysql_typed_convert_bounds( array $tokens, int $position, int $end, array $supported_types ): ?array { + return $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $position, + $end, + array( + 'convert' => $supported_types, + ) + ); + } + private function get_mysql_typed_cast_or_convert_bounds( array $tokens, int $position, int $end, array $supported_types_by_form ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + if ( WP_MySQL_Lexer::CAST_SYMBOL === $tokens[ $position ]->id && isset( $supported_types_by_form['cast'] ) ) { + $form = 'cast'; + $separator_token_id = WP_MySQL_Lexer::AS_SYMBOL; + } elseif ( WP_MySQL_Lexer::CONVERT_SYMBOL === $tokens[ $position ]->id && isset( $supported_types_by_form['convert'] ) ) { + $form = 'convert'; + $separator_token_id = WP_MySQL_Lexer::COMMA_SYMBOL; + } else { + 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; + $separator_position = $this->find_top_level_mysql_token( + $tokens, + $separator_token_id, + $position + 2, + $close_position + ); + if ( null === $separator_position || $separator_position <= $position + 2 ) { + return null; + } + + $type = $this->get_mysql_cast_or_convert_type( + $tokens, + $separator_position + 1, + $close_position + ); + if ( + null === $type + || ! in_array( $type, $supported_types_by_form[ $form ], true ) + ) { + return null; + } + return array( + 'expression_start' => $position + 2, + 'expression_end' => $separator_position, + 'close' => $close_position, + 'type' => $type, + ); + } + private function get_mysql_cast_or_convert_type( array $tokens, int $start, int $end ): ?string { + if ( null !== $this->get_postgresql_integer_cast_type( $tokens, $start, $end ) ) { + return 'integer'; + } + + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + $single_token_cast_types = array( + WP_MySQL_Lexer::CHAR_SYMBOL => 'character', + WP_MySQL_Lexer::DATETIME_SYMBOL => 'date_time', + WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'date_time', + WP_MySQL_Lexer::DATE_SYMBOL => 'date', + WP_MySQL_Lexer::BINARY_SYMBOL => 'binary', + ); + if ( isset( $single_token_cast_types[ $tokens[ $start ]->id ] ) ) { + return $single_token_cast_types[ $tokens[ $start ]->id ]; + } + } + + if ( + isset( $tokens[ $start ] ) + && in_array( $tokens[ $start ]->id, array( WP_MySQL_Lexer::DECIMAL_SYMBOL, WP_MySQL_Lexer::NUMERIC_SYMBOL ), true ) + && ( + $start + 1 === $end + || ( + 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 + ) + ) + ) { + return 'decimal'; + } + return null; + } + 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; + } + private function translate_mysql_regexp_operator_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + $regexp_position = $position; + $negated = false; + if ( WP_MySQL_Lexer::REGEXP_SYMBOL !== $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::NOT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::REGEXP_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $regexp_position = $position + 1; + $negated = true; + } + + if ( + isset( $tokens[ $regexp_position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $regexp_position + 1 ]->id + ) { + return null; + } + + $is_binary = $regexp_position + 1 < $end + && isset( $tokens[ $regexp_position + 1 ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $regexp_position + 1 ]->id; + return array( + 'sql' => $negated ? ( $is_binary ? '!~' : '!~*' ) : ( $is_binary ? '~' : '~*' ), + 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, + 'position' => $is_binary ? $regexp_position + 1 : $regexp_position, + ); + } + private function translate_mysql_rand_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'rand' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || count( $arguments ) > 1 ) { + return null; + } + + $sql = 'random()'; + if ( 1 === count( $arguments ) ) { + $start = $arguments[0]['start']; + $end = $arguments[0]['end']; + $seed = null; + + if ( $start + 1 === $end && isset( $tokens[ $start ] ) && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + $seed = 0; + } elseif ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + $seed = (int) fmod( round( (float) $tokens[ $start ]->get_value(), 0, PHP_ROUND_HALF_EVEN ), 0x100000000 ); + } else { + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null !== $literal && $literal['start'] === $start && $literal['end'] === $end ) { + $seed = (int) fmod( round( (float) $this->get_mysql_token_sequence_bytes( $tokens, $start, $end ), 0, PHP_ROUND_HALF_EVEN ), 0x100000000 ); + } + } + + if ( null !== $seed ) { + $max_value = 0x3FFFFFFF; + $seed_u32 = $seed & 0xFFFFFFFF; + $seed1 = ( ( $seed_u32 * 0x10001 + 55555555 ) & 0xFFFFFFFF ) % $max_value; + $seed2 = ( ( $seed_u32 * 0x10000001 ) & 0xFFFFFFFF ) % $max_value; + $seed1 = ( $seed1 * 3 + $seed2 ) % $max_value; + $literal = rtrim( rtrim( sprintf( '%.17F', (float) $seed1 / (float) $max_value ), '0' ), '.' ); + $sql = sprintf( 'CAST(%s AS double precision)', $literal ); + } else { + $seed_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $start, + $end + ); + $sql = $this->get_postgresql_mysql_seeded_rand_sql( $seed_sql ); + } + } + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + private function contains_unsupported_mysql_rand_function( array $tokens, int $start, int $end ): bool { + return $this->contains_unsupported_mysql_rewrite_range( $tokens, $start, $end, array( 'get_mysql_function_call_bounds', array( 'rand' ) ), array( 'translate_mysql_rand_function_to_postgresql' ) ); + } + private function get_postgresql_mysql_seeded_rand_sql( string $seed_sql ): string { + $max_value = '1073741823'; + $seed_numeric_sql = $this->get_postgresql_mysql_numeric_cast_sql( $seed_sql ); + $seed_sql = sprintf( + '(((CAST(ROUND(COALESCE(%s, 0)) AS bigint) %% 4294967296) + 4294967296) %% 4294967296)', + $seed_numeric_sql + ); + $seed1_sql = sprintf( '((("__wp_pg_mysql_rand_seed"."seed" * 65537 + 55555555) %% %s))', $max_value ); + $seed2_sql = sprintf( '((("__wp_pg_mysql_rand_seed"."seed" * 268435457) %% %s))', $max_value ); + $value_sql = sprintf( '(((%s * 3 + %s) %% %s))', $seed1_sql, $seed2_sql, $max_value ); + return sprintf( + '(SELECT CAST(%1$s AS double precision) / %2$s FROM (SELECT %3$s AS "seed") AS "__wp_pg_mysql_rand_seed")', + $value_sql, + $max_value, + $seed_sql + ); + } + private function translate_mysql_session_user_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $token = $tokens[ $position ] ?? null; + if ( null === $token || ! in_array( $token->id, array( WP_MySQL_Lexer::CURRENT_USER_SYMBOL, WP_MySQL_Lexer::USER_SYMBOL ), true ) ) { + return null; + } + + $function = $this->get_mysql_common_function_name( $token ); + if ( ! in_array( $function, array( 'current_user', 'user', 'session_user', 'system_user' ), true ) ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close || $position + 3 !== $after_close ) { + return null; + } + $position = $after_close - 1; + } elseif ( 'current_user' !== $function ) { + return null; + } + + return $this->get_postgresql_mysql_expression_translation( + $this->connection->quote( self::MYSQL_SESSION_USER ), + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + $position + ); + } + private function translate_mysql_nonparenthesized_timestamp_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && $position + 1 < $end + && in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL ), true ) + ) { + return null; + } + + $function_name = strtolower( $tokens[ $position ]->get_value() ); + $functions = array( + 'current_date' => array( WP_MySQL_Lexer::IDENTIFIER, 'curdate' ), + 'current_time' => array( WP_MySQL_Lexer::IDENTIFIER, 'utc_time' ), + 'current_timestamp' => array( WP_MySQL_Lexer::NOW_SYMBOL, 'current_timestamp' ), + 'localtime' => array( WP_MySQL_Lexer::NOW_SYMBOL, 'localtime' ), + 'localtimestamp' => array( WP_MySQL_Lexer::NOW_SYMBOL, 'localtimestamp' ), + ); + if ( ! isset( $functions[ $function_name ] ) || $functions[ $function_name ][0] !== $tokens[ $position ]->id ) { + return null; + } + $function_name = $functions[ $function_name ][1]; + + $sql = $this->get_postgresql_mysql_common_function_sql( $function_name, array() ); + if ( null === $sql ) { + return null; + } + return $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::IDENTIFIER, $position ); + } + private function get_postgresql_mysql_expression_translation( string $sql, int $token_id, int $position ): array { + return array( + 'sql' => $sql, + 'token_id' => $token_id, + 'position' => $position, + ); + } + private function translate_mysql_common_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + return $this->get_postgresql_mysql_common_function_translation( $tokens, $position, $end ); + } + private function get_postgresql_mysql_common_function_translation( array $tokens, int $position, int $end, ?array $replacements = null, ?array $bounds = null ): ?array { + $bounds = $bounds ?? $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + $arguments = 'substring' === $bounds['function'] + ? $this->get_mysql_substring_function_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ) + : $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return null; + } + if ( 'trim' === $bounds['function'] ) { + $trim_bounds = $this->get_mysql_trim_function_bounds( $tokens, $position, $end ); + if ( null === $trim_bounds ) { + return null; + } + + $value_sql = $this->translate_mysql_common_function_argument_to_postgresql( $tokens, $trim_bounds['argument_start'], $trim_bounds['argument_end'], $replacements ); + if ( null === $value_sql ) { + return null; + } + + $remove_sql = null; + if ( null !== $trim_bounds['remove_start'] && null !== $trim_bounds['remove_end'] ) { + $remove_sql = $this->translate_mysql_common_function_argument_to_postgresql( $tokens, $trim_bounds['remove_start'], $trim_bounds['remove_end'], $replacements ); + if ( null === $remove_sql ) { + return null; + } + } + return $this->get_postgresql_mysql_expression_translation( + $this->get_postgresql_mysql_trim_sql( $trim_bounds['direction'], $value_sql, $trim_bounds['remove'], $remove_sql ), + WP_MySQL_Lexer::IDENTIFIER, + $trim_bounds['close'] + ); + } + + if ( null === $replacements ) { + $translation = $this->get_postgresql_mysql_direct_common_function_translation( $tokens, $position, $bounds, $arguments ); + if ( null !== $translation ) { + return $translation; + } + } + + $argument_sql = array(); + foreach ( $arguments as $argument ) { + $sql = $this->translate_mysql_common_function_argument_to_postgresql( $tokens, $argument['start'], $argument['end'], $replacements ); + if ( null === $sql ) { + return null; + } + $argument_sql[] = $sql; + } + + if ( 'if' === $bounds['function'] && 3 === count( $argument_sql ) ) { + $condition_sql = $this->is_mysql_boolean_condition_expression( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ) + ? '(' . $argument_sql[0] . ')' + : sprintf( 'COALESCE(%s <> 0, false)', $this->get_postgresql_mysql_numeric_cast_sql( $argument_sql[0] ) ); + return $this->get_postgresql_mysql_expression_translation( + sprintf( 'CASE WHEN %s THEN %s ELSE %s END', $condition_sql, $argument_sql[1], $argument_sql[2] ), + WP_MySQL_Lexer::CASE_SYMBOL, + $bounds['close'] + ); + } + + $sql = $this->get_postgresql_mysql_common_function_sql( $bounds['function'], $argument_sql ); + if ( null === $sql ) { + return null; + } + return $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ); + } + private function get_postgresql_mysql_direct_common_function_translation( array $tokens, int $position, array $bounds, array $arguments ): ?array { + $count = count( $arguments ); + $descriptors = array( + array( 'timestampadd', 3, 3, 'translator', 'translate_mysql_timestampadd_function_to_postgresql' ), + array( 'timestampdiff', 3, 3, 'translator', 'translate_mysql_timestampdiff_function_to_postgresql' ), + array( 'from_unixtime', 1, 2, 'null_first_argument' ), + array( 'last_insert_id', 1, 1, 'last_insert_id_assignment' ), + array( 'char_length character_length length', 1, 1, 'binary_length' ), + array( 'from_unixtime', 2, 2, 'from_unixtime_format' ), + array( 'json_valid', 1, 1, 'json_valid_constant' ), + ); + foreach ( $descriptors as $descriptor ) { + if ( ! $this->is_mysql_common_function_descriptor_match( $bounds['function'], $count, $descriptor[0], $descriptor[1], $descriptor[2] ) ) { + continue; + } + $translation = $this->render_postgresql_mysql_direct_common_function_translation( $tokens, $position, $bounds, $arguments, $descriptor ); + if ( null !== $translation ) { + return $translation; + } + } + return null; + } + private function render_postgresql_mysql_direct_common_function_translation( array $tokens, int $position, array $bounds, array $arguments, array $descriptor ): ?array { + switch ( $descriptor[3] ) { + case 'translator': + return $this->{$descriptor[4]}( $tokens, $arguments, $bounds['close'] ); + case 'null_first_argument': + return $this->is_mysql_null_literal_expression( $tokens, $arguments[0]['start'], $arguments[0]['end'] ) ? $this->get_postgresql_mysql_expression_translation( 'NULL', WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ) : null; + case 'last_insert_id_assignment': + if ( ! $this->mysql_last_insert_id_assignment_translation_enabled ) { + return null; + } + $last_insert_id = $this->get_mysql_last_insert_id_assignment_literal_value( $tokens, $position, $bounds['close'] + 1 ); + if ( null === $last_insert_id ) { + return null; + } + $this->mysql_last_insert_id_assignment_value = $last_insert_id; + return $this->get_postgresql_mysql_expression_translation( (string) $last_insert_id, WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ); + case 'binary_length': + $hex_literal = $this->get_mysql_text_hex_literal_value( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + $binary_length_sql = null === $hex_literal ? null : (string) strlen( $hex_literal ); + if ( null === $binary_length_sql ) { + foreach ( array( array( 'unhex', 'hex' ), array( 'from_base64', 'base64' ) ) as $descriptor ) { + $decoded_bounds = $this->get_mysql_function_call_bounds( $tokens, $arguments[0]['start'], $arguments[0]['end'], $descriptor[0] ); + if ( null === $decoded_bounds || $decoded_bounds['close'] + 1 !== $arguments[0]['end'] ) { + continue; + } + $decoded_arguments = $this->split_top_level_mysql_arguments( $tokens, $decoded_bounds['arguments_start'], $decoded_bounds['arguments_end'] ); + if ( null === $decoded_arguments || 1 !== count( $decoded_arguments ) ) { + continue; + } + $argument_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $decoded_arguments[0]['start'], + $decoded_arguments[0]['end'] + ); + $binary_length_sql = 'hex' === $descriptor[1] + ? sprintf( "OCTET_LENGTH(DECODE(CAST(%s AS text), 'hex'))", $argument_sql ) + : sprintf( + 'CASE WHEN %1$s THEN NULL ELSE OCTET_LENGTH(DECODE(CAST(%2$s AS text), \'base64\')) END', + sprintf( + "%1\$s IS NULL OR %1\$s !~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'", + sprintf( 'CAST(%s AS text)', $argument_sql ) + ), + $argument_sql + ); + break; + } + } + if ( null === $binary_length_sql ) { + $binary_cast = $this->get_mysql_typed_cast_or_convert_bounds( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + array( + 'cast' => array( 'binary' ), + 'convert' => array( 'binary' ), + ) + ); + if ( null !== $binary_cast && $binary_cast['close'] + 1 === $arguments[0]['end'] ) { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $binary_cast['expression_start'], + $binary_cast['expression_end'] + ); + $binary_length_sql = $this->get_postgresql_mysql_text_byte_length_sql( $expression_sql ); + } + } + if ( + null === $binary_length_sql + && $arguments[0]['start'] + 1 < $arguments[0]['end'] + && isset( $tokens[ $arguments[0]['start'] ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $arguments[0]['start'] ]->id + ) { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'] + 1, + $arguments[0]['end'] + ); + $binary_length_sql = $this->get_postgresql_mysql_text_byte_length_sql( $expression_sql ); + } + return null === $binary_length_sql ? null : $this->get_postgresql_mysql_expression_translation( $binary_length_sql, WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ); + case 'from_unixtime_format': + $format = $this->get_mysql_constant_string_argument_value( $tokens, $arguments[1] ); + $timestamp_sql = $this->get_postgresql_mysql_from_unixtime_timestamp_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $arguments[0]['start'], $arguments[0]['end'] ) + ); + if ( null !== $format ) { + $sql = $format['is_null'] ? 'NULL' : $this->get_postgresql_mysql_generic_date_format_sql( $format['value'], $timestamp_sql, false ); + return null === $sql ? null : $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ); + } + + $sql = $this->get_postgresql_mysql_finite_date_format_choice_sql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'], + $timestamp_sql, + true + ); + return null === $sql ? null : $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::CASE_SYMBOL, $bounds['close'] ); + case 'json_valid_constant': + $json_value = $this->get_mysql_constant_string_argument_value( $tokens, $arguments[0] ); + return null === $json_value ? null : $this->get_postgresql_mysql_expression_translation( $json_value['is_null'] ? 'NULL' : (string) self::get_mysql_json_valid_runtime_result( $json_value['value'] ), WP_MySQL_Lexer::IDENTIFIER, $bounds['close'] ); + } + return null; + } + private function translate_mysql_common_function_argument_to_postgresql( array $tokens, int $start, int $end, ?array $replacements ): ?string { + return null === $replacements + ? $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ) + : $this->translate_mysql_upsert_expression_subrange_with_replacements_to_postgresql( $tokens, $start, $end, $replacements ); + } + private function get_mysql_common_function_bounds( array $tokens, int $position, int $end ): ?array { + $function = $this->get_mysql_common_function_name( $tokens[ $position ] ?? null ); + if ( + null === $function + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + return array( + 'function' => $function, + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + private function get_mysql_common_function_name( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + $keyword_functions = array_combine( + array( WP_MySQL_Lexer::ASCII_SYMBOL, WP_MySQL_Lexer::COALESCE_SYMBOL, WP_MySQL_Lexer::CURRENT_USER_SYMBOL, WP_MySQL_Lexer::CURDATE_SYMBOL, WP_MySQL_Lexer::CURTIME_SYMBOL, WP_MySQL_Lexer::CURRENT_DATE_SYMBOL, WP_MySQL_Lexer::CURRENT_TIME_SYMBOL, WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL, WP_MySQL_Lexer::DATABASE_SYMBOL, WP_MySQL_Lexer::DATE_SYMBOL, WP_MySQL_Lexer::IF_SYMBOL, WP_MySQL_Lexer::LEFT_SYMBOL, WP_MySQL_Lexer::MID_SYMBOL, WP_MySQL_Lexer::NOW_SYMBOL, WP_MySQL_Lexer::REPLACE_SYMBOL, WP_MySQL_Lexer::REGEXP_SYMBOL, WP_MySQL_Lexer::REPEAT_SYMBOL, WP_MySQL_Lexer::REVERSE_SYMBOL, WP_MySQL_Lexer::RIGHT_SYMBOL, WP_MySQL_Lexer::ROW_COUNT_SYMBOL, WP_MySQL_Lexer::SCHEMA_SYMBOL, WP_MySQL_Lexer::SUBSTR_SYMBOL, WP_MySQL_Lexer::SUBSTRING_SYMBOL, WP_MySQL_Lexer::TRIM_SYMBOL, WP_MySQL_Lexer::UTC_DATE_SYMBOL, WP_MySQL_Lexer::UTC_TIME_SYMBOL, WP_MySQL_Lexer::UTC_TIMESTAMP_SYMBOL ), + explode( ' ', 'ascii coalesce current_user curdate utc_time curdate utc_time utc_timestamp database date if left substring now replace regexp repeat reverse right row_count database substring substring trim utc_date utc_time utc_timestamp' ) + ); + if ( isset( $keyword_functions[ $token->id ] ) ) { + return $keyword_functions[ $token->id ]; + } + if ( WP_MySQL_Lexer::USER_SYMBOL === $token->id ) { + $name = strtolower( $token->get_value() ); + return in_array( $name, array( 'user', 'session_user', 'system_user' ), true ) ? $name : null; + } + $name = $this->get_mysql_identifier_token_value( $token ); + if ( null === $name ) { + return null; + } + $name = strtolower( $name ); + $aliases = array_combine( + explode( ' ', 'current_date current_time current_timestamp' ), + explode( ' ', 'curdate utc_time utc_timestamp' ) + ); + $supported = explode( ' ', 'ascii char_length character_length concat concat_ws connection_id curdate current_user database date dayname datediff elt find_in_set found_rows from_base64 from_unixtime get_lock greatest hex if ifnull inet_aton inet_ntoa instr isnull is_uuid json_valid lcase last_insert_id left least length locate log lower lpad localtime localtimestamp ltrim make_set md5 monthnum monthname now nullif release_lock replace regexp repeat reverse right rpad rtrim row_count schema session_user space substr substring system_user timestampadd timestampdiff to_base64 trim ucase unhex unix_timestamp upper utc_date utc_time utc_timestamp user version uuid' ); + return $aliases[ $name ] ?? ( in_array( $name, $supported, true ) ? $name : null ); + } + private function contains_unsupported_mysql_common_function( array $tokens, int $start, int $end ): bool { + return $this->contains_unsupported_mysql_rewrite_range( $tokens, $start, $end, array( 'get_mysql_common_function_bounds' ), array( 'translate_mysql_common_function_to_postgresql' ) ); + } + private function contains_unsupported_mysql_convert_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + ! isset( $tokens[ $i ], $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $i ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + if ( + null !== $this->get_mysql_convert_using_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_typed_convert_bounds( + $tokens, + $i, + $end, + array( 'integer', 'character', 'binary', 'decimal', 'date' ) + ) + ) { + continue; + } + return true; + } + return false; + } + private function contains_unsupported_mysql_fulltext_search_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + for ( $i = 0; $i < $statement_end; $i++ ) { + if ( + WP_MySQL_Lexer::MATCH_SYMBOL !== $tokens[ $i ]->id + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + $after_match = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $statement_end ); + if ( + null !== $after_match + && isset( $tokens[ $after_match ], $tokens[ $after_match + 1 ] ) + && WP_MySQL_Lexer::AGAINST_SYMBOL === $tokens[ $after_match ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $after_match + 1 ]->id + ) { + return true; + } + } + return false; + } + private function contains_unsupported_mysql_group_concat_function( array $tokens, int $start, int $end ): bool { + return $this->contains_unsupported_mysql_rewrite_range( $tokens, $start, $end, array( 'get_mysql_group_concat_function_bounds' ), array( 'translate_mysql_group_concat_function_to_postgresql' ) ); + } + private function contains_unsupported_mysql_group_concat_function_query( string $query ): bool { + return $this->contains_unsupported_mysql_range_scanner_query( $query, array( 'contains_unsupported_mysql_group_concat_function' ) ); + } + private function get_postgresql_mysql_trim_sql( string $direction, string $value_sql, ?string $remove, ?string $remove_sql = null ): string { + $value_text_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + if ( null === $remove ) { + if ( null === $remove_sql ) { + return $value_text_sql; + } + $remove_text_sql = sprintf( 'CAST(%s AS text)', $remove_sql ); + $escaped_remove_sql = sprintf( + "REGEXP_REPLACE(%s, %s, %s, 'g')", + $remove_text_sql, + $this->connection->quote( '([\\\\.^$|?*+()[\]{}])' ), + $this->connection->quote( '\\\\\1' ) + ); + $leading_pattern = sprintf( "( '^(' || %s || ')+' )", $escaped_remove_sql ); + $trailing_pattern = sprintf( "( '(' || %s || ')+$' )", $escaped_remove_sql ); + if ( 'leading' === $direction ) { + $trimmed_sql = sprintf( "REGEXP_REPLACE(%s, %s, '')", $value_text_sql, $leading_pattern ); + } elseif ( 'trailing' === $direction ) { + $trimmed_sql = sprintf( "REGEXP_REPLACE(%s, %s, '')", $value_text_sql, $trailing_pattern ); + } else { + $trimmed_sql = sprintf( + "REGEXP_REPLACE(REGEXP_REPLACE(%s, %s, ''), %s, '')", + $value_text_sql, + $leading_pattern, + $trailing_pattern + ); + } + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s = \'\' THEN %1$s ELSE %3$s END', + $value_text_sql, + $remove_text_sql, + $trimmed_sql + ); + } + if ( '' === $remove ) { + return $value_text_sql; + } + if ( ' ' === $remove ) { + $function = array( + 'both' => 'BTRIM', + 'leading' => 'LTRIM', + 'trailing' => 'RTRIM', + )[ $direction ]; + return sprintf( "%s(%s, ' ')", $function, $value_text_sql ); + } + $remove_pattern = (string) preg_replace( '/([\\\\.^$|?*+()[\]{}])/', '\\\\$1', $remove ); + if ( 'leading' === $direction ) { + return sprintf( 'REGEXP_REPLACE(%s, %s, \'\')', $value_text_sql, $this->connection->quote( '^(' . $remove_pattern . ')+' ) ); + } + if ( 'trailing' === $direction ) { + return sprintf( 'REGEXP_REPLACE(%s, %s, \'\')', $value_text_sql, $this->connection->quote( '(' . $remove_pattern . ')+$' ) ); + } + return sprintf( + 'REGEXP_REPLACE(REGEXP_REPLACE(%s, %s, \'\'), %s, \'\')', + $value_text_sql, + $this->connection->quote( '^(' . $remove_pattern . ')+' ), + $this->connection->quote( '(' . $remove_pattern . ')+$' ) + ); + } + private function get_postgresql_mysql_common_function_sql( string $function_name, array $argument_sql ): ?string { + $simple_sql = $this->get_postgresql_mysql_common_function_sql_from_descriptors( + $function_name, + $argument_sql, + self::MYSQL_SIMPLE_COMMON_FUNCTION_REWRITE_DESCRIPTORS + ); + if ( null !== $simple_sql ) { + return $simple_sql; + } + + $count = count( $argument_sql ); + $descriptors = array( + 'hex' => array( 1, 1, 'template', "UPPER(ENCODE(CONVERT_TO(CAST(%s AS text), 'UTF8'), 'hex'))" ), + 'inet_aton' => array( 1, 1, 'template', 'CASE WHEN CAST(%1$s AS text) IS NULL THEN NULL ELSE ((CAST(SPLIT_PART(CAST(%1$s AS text), \'.\', 1) AS bigint) << 24) + (CAST(SPLIT_PART(CAST(%1$s AS text), \'.\', 2) AS bigint) << 16) + (CAST(SPLIT_PART(CAST(%1$s AS text), \'.\', 3) AS bigint) << 8) + CAST(SPLIT_PART(CAST(%1$s AS text), \'.\', 4) AS bigint)) END' ), + 'inet_ntoa' => array( 1, 1, 'template', 'CASE WHEN CAST(%1$s AS bigint) IS NULL THEN NULL ELSE (((CAST(%1$s AS bigint) >> 24) & 255)::text || \'.\' || ((CAST(%1$s AS bigint) >> 16) & 255)::text || \'.\' || ((CAST(%1$s AS bigint) >> 8) & 255)::text || \'.\' || (CAST(%1$s AS bigint) & 255)::text) END' ), + 'is_uuid' => array( 1, 1, 'template', 'CASE WHEN CAST(%1$s AS text) IS NULL THEN NULL WHEN CAST(%1$s AS text) ~* ' . $this->connection->quote( '^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\})$' ) . ' THEN 1 ELSE 0 END' ), + 'left' => array( 2, 2, 'template', 'LEFT(CAST(%s AS text), CAST(%s AS integer))' ), + 'repeat' => array( 2, 2, 'template', 'CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL ELSE REPEAT(CAST(%1$s AS text), GREATEST(CAST(%2$s AS integer), 0)) END' ), + 'regexp' => array( 2, 2, 'template', 'CASE WHEN CAST(%1$s AS text) IS NULL OR CAST(%2$s AS text) IS NULL THEN NULL WHEN CAST(%2$s AS text) ~* CAST(%1$s AS text) THEN 1 ELSE 0 END' ), + 'right' => array( 2, 2, 'template', 'RIGHT(CAST(%s AS text), CAST(%s AS integer))' ), + 'space' => array( 1, 1, 'template', "CASE WHEN %1\$s IS NULL THEN NULL ELSE REPEAT(' ', GREATEST(CAST(%1\$s AS integer), 0)) END" ), + 'to_base64' => array( 1, 1, 'template', "ENCODE(CONVERT_TO(CAST(%s AS text), 'UTF8'), 'base64')" ), + 'trim' => array( 1, 1, 'template', "BTRIM(CAST(%s AS text), ' ')" ), + 'unhex' => array( 1, 1, 'template', "CONVERT_FROM(DECODE(CAST(%s AS text), 'hex'), 'UTF8')" ), + 'concat_ws' => array( 2, null, 'method', 'get_postgresql_mysql_concat_ws_sql' ), + 'elt' => array( 2, null, 'method', 'get_postgresql_mysql_elt_sql' ), + 'log' => array( 1, 2, 'method', 'get_postgresql_mysql_log_sql' ), + 'make_set' => array( 2, 64, 'method', 'get_postgresql_mysql_make_set_sql' ), + 'substr substring' => array( 2, 3, 'method', 'get_postgresql_mysql_substring_sql' ), + 'date' => array( 1, 1, 'argument_method', 'get_postgresql_mysql_ymd_format_sql' ), + 'find_in_set' => array( 2, 2, 'argument_method', 'get_postgresql_mysql_find_in_set_sql' ), + 'json_valid' => array( 1, 1, 'argument_method', 'get_postgresql_mysql_json_valid_sql' ), + 'length' => array( 1, 1, 'argument_method', 'get_postgresql_mysql_text_byte_length_sql' ), + 'locate' => array( 3, 3, 'argument_method', 'get_postgresql_mysql_locate_with_position_sql' ), + 'connection_id' => array( 0, 0, 'sql', (string) self::MYSQL_CONNECTION_ID ), + 'curdate utc_date' => array( 0, 0, 'sql', "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD')" ), + 'database schema' => array( 0, 0, 'sql', $this->connection->quote( $this->db_name ) ), + 'found_rows' => array( 0, 0, 'sql', (string) $this->last_found_rows ), + 'row_count' => array( 0, 0, 'sql', (string) $this->last_row_count ), + 'uuid' => array( 0, 0, 'sql', "LOWER(REGEXP_REPLACE(MD5(CAST(CLOCK_TIMESTAMP() AS text) || CAST(RANDOM() AS text) || CAST(PG_BACKEND_PID() AS text)), '^(.{8})(.{4}).(.{3}).(.{3})(.{12})$', '\\1-\\2-4\\3-8\\4-\\5'))" ), + 'version' => array( 0, 0, 'sql', $this->connection->quote( $this->get_mysql_version_string() ) ), + 'current_user session_user system_user user' => array( 0, 0, 'sql', $this->connection->quote( self::MYSQL_SESSION_USER ) ), + 'get_lock' => array( 2, 2, 'sql', '1' ), + 'release_lock' => array( 1, 1, 'sql', '1' ), + 'dayname' => array( 1, 1, 'temporal_name', 'DOW', explode( ' ', 'Sunday Monday Tuesday Wednesday Thursday Friday Saturday' ) ), + 'monthname' => array( 1, 1, 'temporal_name', 'MONTH', array_combine( range( 1, 12 ), explode( ' ', 'January February March April May June July August September October November December' ) ) ), + 'ascii' => array( 1, 1, 'ascii' ), + 'utc_time' => array( 0, 1, 'utc_time' ), + 'current_timestamp localtime localtimestamp now utc_timestamp' => array( 0, 1, 'current_timestamp' ), + 'lpad rpad' => array( 3, 3, 'pad' ), + 'last_insert_id' => array( 0, 0, 'last_insert_id' ), + 'least greatest' => array( 2, null, 'null_sensitive_variadic' ), + 'from_base64' => array( 1, 1, 'from_base64' ), + 'datediff' => array( 2, 2, 'datediff' ), + 'from_unixtime' => array( 1, 2, 'from_unixtime' ), + 'monthnum' => array( 1, 1, 'zero_date_extract', 'MONTH' ), + 'unix_timestamp' => array( 0, 1, 'unix_timestamp' ), + ); + + foreach ( $descriptors as $names => $descriptor ) { + if ( $this->is_mysql_common_function_descriptor_match( $function_name, $count, $names, $descriptor[0], $descriptor[1] ) ) { + return $this->render_postgresql_mysql_common_function_sql_descriptor( $function_name, $argument_sql, $descriptor ); + } + } + return null; + } + private function get_postgresql_mysql_common_function_sql_from_descriptors( string $function_name, array $argument_sql, array $descriptors ): ?string { + $count = count( $argument_sql ); + foreach ( $descriptors as $descriptor ) { + if ( $this->is_mysql_common_function_descriptor_match( $function_name, $count, $descriptor[0], $descriptor[1], $descriptor[2] ) ) { + return $this->render_postgresql_mysql_common_function_sql_descriptor( $function_name, $argument_sql, array_slice( $descriptor, 1 ) ); + } + } + return null; + } + private function is_mysql_common_function_descriptor_match( string $function_name, int $argument_count, string $names, int $minimum, ?int $maximum ): bool { + return false !== strpos( ' ' . $names . ' ', ' ' . $function_name . ' ' ) + && $argument_count >= $minimum + && ( null === $maximum || $argument_count <= $maximum ); + } + private function render_postgresql_mysql_common_function_sql_descriptor( string $function_name, array $argument_sql, array $descriptor ): ?string { + switch ( $descriptor[2] ) { + case 'template': + return vsprintf( $descriptor[3], $argument_sql ); + case 'method': + return $this->{$descriptor[3]}( $argument_sql ); + case 'argument_method': + return $this->{$descriptor[3]}( ...$argument_sql ); + case 'sql': + return $descriptor[3]; + case 'temporal_name': + $expression_text_sql = sprintf( 'CAST(%s AS text)', $argument_sql[0] ); + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $argument_sql[0] ); + $branches = array(); + foreach ( $descriptor[4] as $value => $name ) { + $branches[] = sprintf( 'WHEN %d THEN %s', $value, $this->connection->quote( $name ) ); + } + return sprintf( 'CASE WHEN %1$s OR %2$s THEN NULL ELSE CASE CAST(EXTRACT(%3$s FROM %4$s) AS integer) %5$s END END', $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), $descriptor[3], $timestamp_sql, implode( ' ', $branches ) ); + case 'variadic_template': + return sprintf( $descriptor[3], implode( ', ', $argument_sql ) ); + case 'cast_join': + return '(CAST(' . implode( ' AS text)' . $descriptor[3] . 'CAST(', $argument_sql ) . ' AS text))'; + case 'zero_date_extract': + return $this->get_postgresql_zero_date_safe_extract_sql( $descriptor[3], $argument_sql[0] ); + case 'pad': + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s IS NULL OR CAST(%2$s AS integer) < 0 OR CAST(%3$s AS text) = \'\' THEN NULL ELSE %4$s(CAST(%1$s AS text), CAST(%2$s AS integer), CAST(%3$s AS text)) END', + $argument_sql[0], + $argument_sql[1], + $argument_sql[2], + strtoupper( $function_name ) + ); + case 'null_sensitive_variadic': + return sprintf( 'CASE WHEN %1$s IS NULL THEN NULL ELSE %2$s(%3$s) END', implode( ' IS NULL OR ', $argument_sql ), strtoupper( $function_name ), implode( ', ', $argument_sql ) ); + case 'ascii': + $text_sql = sprintf( 'CAST(%s AS text)', $argument_sql[0] ); + $prefix_chars = preg_match_all( '/./us', self::MYSQL_TEXT_ENCODING_PREFIX ); + $prefix_length = false === $prefix_chars ? strlen( self::MYSQL_TEXT_ENCODING_PREFIX ) : $prefix_chars; + $prefix_sql = $this->connection->get_pdo()->quote( self::MYSQL_TEXT_ENCODING_PREFIX ); + $payload_sql = sprintf( 'SUBSTR(%s, %d)', $text_sql, $prefix_length + 1 ); + $separator_sql = sprintf( "STRPOS(%s, ':')", $payload_sql ); + $length_sql = sprintf( 'SUBSTR(%s, 1, GREATEST(%s - 1, 0))', $payload_sql, $separator_sql ); + $after_length_sql = sprintf( 'SUBSTR(%s, GREATEST(%s + 1, 1))', $payload_sql, $separator_sql ); + $hash_separator_sql = sprintf( "STRPOS(%s, ':')", $after_length_sql ); + $hash_sql = sprintf( 'SUBSTR(%s, 1, GREATEST(%s - 1, 0))', $after_length_sql, $hash_separator_sql ); + $hex_sql = sprintf( 'SUBSTR(%s, GREATEST(%s + 1, 1))', $after_length_sql, $hash_separator_sql ); + $envelope_sql = sprintf( + "SUBSTR(%1\$s, 1, %2\$d) = %3\$s AND %4\$s > 1 AND %5\$s > 1 AND TRANSLATE(%6\$s, '0123456789', '') = '' AND (%6\$s = '0' OR SUBSTR(%6\$s, 1, 1) <> '0') AND %7\$s ~ '^[0-9a-f]{64}$' AND MOD(CHAR_LENGTH(%8\$s), 2) = 0 AND %8\$s ~ '^[0-9a-f]*$' AND (%6\$s = '0' OR CHAR_LENGTH(%8\$s) >= 2)", + $text_sql, + $prefix_length, + $prefix_sql, + $separator_sql, + $hash_separator_sql, + $length_sql, + $hash_sql, + $hex_sql + ); + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %2\$s THEN CASE WHEN CAST(%3\$s AS bigint) = 0 THEN 0 ELSE GET_BYTE(DECODE(SUBSTR(%4\$s, 1, 2), 'hex'), 0) END WHEN %1\$s = '' THEN 0 ELSE GET_BYTE(CONVERT_TO(%1\$s, 'UTF8'), 0) END", + $text_sql, + $envelope_sql, + $length_sql, + $hex_sql + ); + case 'utc_time': + $fsp = $this->get_mysql_temporal_function_fractional_seconds_precision( $argument_sql ); + if ( null === $fsp ) { + return null; + } + return 0 === $fsp + ? "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS')" + : sprintf( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(%1\$d) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), %2\$d)", $fsp, 9 + $fsp ); + case 'current_timestamp': + $fsp = $this->get_mysql_temporal_function_fractional_seconds_precision( $argument_sql ); + return null === $fsp ? null : $this->get_postgresql_mysql_current_timestamp_sql( $fsp ); + case 'last_insert_id': + $last_insert_id = null !== $this->mysql_last_insert_id_assignment_value ? $this->mysql_last_insert_id_assignment_value : $this->get_insert_id(); + return is_numeric( $last_insert_id ) ? (string) (int) $last_insert_id : '0'; + case 'from_base64': + return sprintf( + 'CASE WHEN %1$s THEN NULL ELSE CONVERT_FROM(DECODE(CAST(%2$s AS text), \'base64\'), \'UTF8\') END', + sprintf( + "%1\$s IS NULL OR %1\$s !~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'", + sprintf( 'CAST(%s AS text)', $argument_sql[0] ) + ), + $argument_sql[0] + ); + case 'datediff': + return sprintf( + 'CAST((CAST(%1$s AS date) - CAST(%2$s AS date)) AS integer)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $argument_sql[0] ), + $this->get_postgresql_zero_date_safe_timestamp_sql( $argument_sql[1] ) + ); + case 'from_unixtime': + $timestamp_sql = $this->get_postgresql_mysql_from_unixtime_timestamp_sql( $argument_sql[0] ); + if ( 1 === count( $argument_sql ) ) { + $unix_double_sql = sprintf( 'CAST(%s AS double precision)', $argument_sql[0] ); + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %1\$s = FLOOR(%1\$s) THEN TO_CHAR(%2\$s, 'YYYY-MM-DD HH24:MI:SS') ELSE TO_CHAR(%2\$s, 'YYYY-MM-DD HH24:MI:SS.US') END", + $unix_double_sql, + $timestamp_sql + ); + } + $format = $this->get_mysql_sql_string_literal_value( $argument_sql[1] ); + return null !== $format + ? $this->get_postgresql_mysql_generic_date_format_sql( $format, $timestamp_sql, false ) + : $this->get_postgresql_mysql_dynamic_date_format_sql( $argument_sql[1], $timestamp_sql ); + case 'unix_timestamp': + return 0 === count( $argument_sql ) + ? 'CAST(FLOOR(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)) AS bigint)' + : sprintf( 'CAST(FLOOR(EXTRACT(EPOCH FROM %s)) AS bigint)', $this->get_postgresql_zero_date_safe_timestamp_sql( $argument_sql[0] ) ); + } + return null; + } + private function get_postgresql_mysql_json_valid_sql( string $argument_sql ): string { + if ( 'NULL' === strtoupper( trim( $argument_sql ) ) ) { + return 'NULL'; + } + $literal_value = $this->get_mysql_sql_string_literal_value( $argument_sql ); + if ( null !== $literal_value ) { + return (string) self::get_mysql_json_valid_runtime_result( $literal_value ); + } + if ( 1 === preg_match( '/^-?(?:0|[1-9][0-9]*)(?:[.][0-9]+)?(?:[eE][+-]?[0-9]+)?$/', trim( $argument_sql ) ) ) { + return (string) self::get_mysql_json_valid_runtime_result( trim( $argument_sql ) ); + } + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN pg_input_is_valid(CAST(%1$s AS text), %2$s) THEN 1 ELSE 0 END', + $argument_sql, + $this->connection->quote( 'json' ) + ); + } + private function get_postgresql_mysql_from_unixtime_timestamp_sql( string $unix_timestamp_sql ): string { + $timestamp_sql = sprintf( "TO_TIMESTAMP(CAST(%s AS double precision)) AT TIME ZONE 'UTC'", $unix_timestamp_sql ); + $time_zone = trim( $this->get_mysql_system_variable_value( 'time_zone' ) ?? 'SYSTEM', "'\"` \t\n\r\0\x0B" ); + if ( '' === $time_zone || 0 === strcasecmp( $time_zone, 'SYSTEM' ) || 0 === strcasecmp( $time_zone, 'UTC' ) || 1 !== preg_match( '/\A([+-])([0-9]{2}):([0-9]{2})\z/', $time_zone, $matches ) ) { + return $timestamp_sql; + } + $offset = ( ( (int) $matches[2] * 60 ) + (int) $matches[3] ) * ( '-' === $matches[1] ? -1 : 1 ); + if ( 0 === $offset ) { + return $timestamp_sql; + } + return sprintf( + '(%s + INTERVAL %s)', + $timestamp_sql, + $this->connection->quote( $offset . ' minutes' ) + ); + } + private function ensure_postgresql_runtime_helpers_for_query( string $query ): void { + $this->ensure_postgresql_mysql_domains( 'text', $this->get_postgresql_mysql_text_domain_definitions_for_query( $query ) ); + $this->ensure_postgresql_mysql_domains( 'binary', $this->get_postgresql_mysql_binary_domain_definitions_for_query( $query ) ); + $this->ensure_postgresql_mysql_domains( 'integer', $this->get_postgresql_mysql_integer_domain_definitions_for_query( $query ) ); + $this->ensure_postgresql_mysql_domains( 'numeric', $this->get_postgresql_mysql_numeric_domain_definitions_for_query( $query ) ); + } + private function get_postgresql_mysql_text_domain_definitions_for_query( string $query ): array { + $domain_definitions = array(); + foreach ( array_keys( self::MYSQL_TEXT_DOMAIN_TYPES ) as $domain_name ) { + if ( false !== strpos( $query, $domain_name ) ) { + $domain_definitions[ $domain_name ] = 'text'; + } + } + return $domain_definitions; + } + private function get_postgresql_mysql_binary_domain_definitions_for_query( string $query ): array { + $domain_definitions = array(); + preg_match_all( '/\b__wp_mysql_(?:(?:var)?binary(?:_[0-9]+)?|tinyblob|blob|mediumblob|longblob)\b/', $query, $matches ); + foreach ( $matches[0] as $domain_name ) { + $domain_definitions[ $domain_name ] = 'bytea'; + } + return $domain_definitions; + } + private function ensure_postgresql_mysql_domains( string $kind, array $domain_definitions ): void { + foreach ( $domain_definitions as $domain_name => $base_type ) { + $this->connection->query( + sprintf( + 'DO $wp_mysql_%1$s_domain$ +BEGIN + CREATE DOMAIN %2$s AS %3$s; +EXCEPTION WHEN duplicate_object THEN + NULL; +END; +$wp_mysql_%1$s_domain$', + $kind, + $this->connection->quote_identifier( $domain_name ), + $base_type + ) + ); + } + } + private function get_postgresql_mysql_integer_domain_definitions_for_query( string $query ): array { + $domain_definitions = array(); + preg_match_all( '/\b__wp_mysql_(bit|bool|boolean|tinyint|smallint|mediumint|int|int1|int2|int3|int4|int8|bigint)(?:_([0-9]+))?(_unsigned)?\b/', $query, $matches, PREG_SET_ORDER ); + foreach ( $matches as $match ) { + $type = $match[1]; + if ( ! isset( self::MYSQL_INTEGER_DOMAIN_BASE_TYPES[ $type ] ) ) { + continue; + } + + $domain_definitions[ $match[0] ] = self::MYSQL_INTEGER_DOMAIN_BASE_TYPES[ $type ]; + } + return $domain_definitions; + } + private function get_postgresql_mysql_numeric_domain_definitions_for_query( string $query ): array { + $domain_definitions = array(); + preg_match_all( '/\b__wp_mysql_(dec|fixed|float|double|real|numeric)(?:_([0-9]+)(?:_([0-9]+))?)?\b/', $query, $matches, PREG_SET_ORDER ); + foreach ( $matches as $match ) { + $type = $match[1]; + $precision = $match[2] ?? ''; + $scale = $match[3] ?? ''; + + if ( '' !== $precision ) { + $base_type = 'numeric(' . (int) $precision . ( '' === $scale ? '' : ',' . (int) $scale ) . ')'; + } elseif ( in_array( $type, array( 'float', 'double', 'real' ), true ) ) { + $base_type = 'double precision'; + } else { + $base_type = 'numeric'; + } + + $domain_definitions[ $match[0] ] = $base_type; + } + return $domain_definitions; + } + private static function get_mysql_json_valid_runtime_result( $value ): ?int { + if ( null === $value ) { + return null; + } + + json_decode( (string) $value ); + return JSON_ERROR_NONE === json_last_error() ? 1 : 0; + } + private function get_mysql_temporal_function_fractional_seconds_precision( array $argument_sql ): ?int { + $count = count( $argument_sql ); + if ( 0 === $count ) { + return 0; + } + if ( 1 !== $count ) { + return null; + } + return 1 === preg_match( '/^[0-6]$/', trim( $argument_sql[0] ) ) ? (int) trim( $argument_sql[0] ) : null; + } + private function get_postgresql_mysql_current_timestamp_sql( int $fsp ): string { + return 0 === $fsp + ? "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')" + : sprintf( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(%1\$d) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), %2\$d)", $fsp, 20 + $fsp ); + } + private function get_postgresql_mysql_ymd_format_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + return sprintf( 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN SUBSTRING(%3$s FROM 1 FOR 10) ELSE TO_CHAR(%4$s, %5$s) END', $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), $expression_text_sql, $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), $this->connection->quote( 'YYYY-MM-DD' ) ); + } + private function get_mysql_substring_function_arguments( array $tokens, int $start, int $end ): ?array { + $arguments = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null !== $arguments && ( 2 === count( $arguments ) || 3 === count( $arguments ) ) ) { + return $arguments; + } + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $start, $end ); + if ( null === $from_position || $from_position <= $start || $from_position + 1 >= $end ) { + return null; + } + $for_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FOR_SYMBOL, $from_position + 1, $end ); + if ( null === $for_position ) { + return array( + $this->get_mysql_argument_range( $start, $from_position ), + $this->get_mysql_argument_range( $from_position + 1, $end ), + ); + } + if ( $for_position <= $from_position + 1 || $for_position + 1 >= $end ) { + return null; + } + return array( + $this->get_mysql_argument_range( $start, $from_position ), + $this->get_mysql_argument_range( $from_position + 1, $for_position ), + $this->get_mysql_argument_range( $for_position + 1, $end ), + ); + } + private function get_mysql_argument_range( int $start, int $end ): array { + return compact( 'start', 'end' ); + } + private function get_postgresql_mysql_text_byte_length_sql( string $argument_sql ): string { + $text_sql = sprintf( 'CAST(%s AS text)', $argument_sql ); + $prefix_chars = preg_match_all( '/./us', self::MYSQL_TEXT_ENCODING_PREFIX ); + $prefix_length = false === $prefix_chars ? strlen( self::MYSQL_TEXT_ENCODING_PREFIX ) : $prefix_chars; + $prefix_sql = $this->connection->get_pdo()->quote( self::MYSQL_TEXT_ENCODING_PREFIX ); + $payload_sql = sprintf( 'SUBSTR(%s, %d)', $text_sql, $prefix_length + 1 ); + $separator_sql = sprintf( "STRPOS(%s, ':')", $payload_sql ); + $length_sql = sprintf( 'SUBSTR(%s, 1, %s - 1)', $payload_sql, $separator_sql ); + return sprintf( "CASE WHEN SUBSTR(%1\$s, 1, %2\$d) = %3\$s AND %4\$s > 1 AND TRANSLATE(%5\$s, '0123456789', '') = '' AND (%5\$s = '0' OR SUBSTR(%5\$s, 1, 1) <> '0') THEN CAST(%5\$s AS bigint) ELSE OCTET_LENGTH(CONVERT_TO(%1\$s, 'UTF8')) END", $text_sql, $prefix_length, $prefix_sql, $separator_sql, $length_sql ); + } + private function get_postgresql_mysql_concat_ws_sql( array $argument_sql ): string { + $separator_sql = $argument_sql[0]; + $value_sql = array_slice( $argument_sql, 1 ); + $fragments = array(); + $seen_value_sql = array(); + foreach ( $value_sql as $index => $sql ) { + if ( $index > 0 ) { + $fragments[] = sprintf( 'CASE WHEN (%1$s) AND %2$s IS NOT NULL THEN CAST(%3$s AS text) ELSE \'\' END', implode( ' OR ', $seen_value_sql ), $sql, $separator_sql ); + } + $fragments[] = sprintf( 'COALESCE(CAST(%s AS text), \'\')', $sql ); + $seen_value_sql[] = sprintf( '%s IS NOT NULL', $sql ); + } + return sprintf( 'CASE WHEN %1$s IS NULL THEN NULL ELSE (%2$s) END', $separator_sql, implode( ' || ', $fragments ) ); + } + private function get_postgresql_mysql_elt_sql( array $argument_sql ): string { + $index_sql = $this->get_postgresql_mysql_integer_cast_sql( $argument_sql[0] ); + $branches = array( sprintf( 'WHEN %s IS NULL THEN NULL', $index_sql ) ); + for ( $i = 1; $i < count( $argument_sql ); $i++ ) { + $branches[] = sprintf( 'WHEN %1$s = %2$d THEN CAST(%3$s AS text)', $index_sql, $i, $argument_sql[ $i ] ); + } + return 'CASE ' . implode( ' ', $branches ) . ' ELSE NULL END'; + } + private function get_postgresql_mysql_find_in_set_sql( string $needle_sql, string $list_sql ): string { + $needle_text_sql = sprintf( 'CAST(%s AS text)', $needle_sql ); + $list_text_sql = sprintf( 'CAST(%s AS text)', $list_sql ); + return sprintf( "CASE WHEN %1\$s IS NULL OR %2\$s IS NULL THEN NULL WHEN STRPOS(%1\$s, ',') > 0 THEN 0 ELSE COALESCE(ARRAY_POSITION(STRING_TO_ARRAY(%2\$s, ','), %1\$s), 0) END", $needle_text_sql, $list_text_sql ); + } + private function get_postgresql_mysql_make_set_sql( array $argument_sql ): ?string { + $mask_sql = $this->get_postgresql_mysql_integer_cast_sql( $argument_sql[0] ); + $values_sql = array(); + for ( $i = 1; $i < count( $argument_sql ); $i++ ) { + $bit_sql = (string) ( 1 << ( $i - 1 ) ); + $values_sql[] = sprintf( 'CASE WHEN (%1$s & %2$s) <> 0 THEN CAST(%3$s AS text) ELSE NULL END', $mask_sql, $bit_sql, $argument_sql[ $i ] ); + } + return sprintf( 'CASE WHEN %1$s IS NULL THEN NULL ELSE CONCAT_WS(\',\', %2$s) END', $mask_sql, implode( ', ', $values_sql ) ); + } + private function get_postgresql_mysql_log_sql( array $argument_sql ): ?string { + $argument_count = count( $argument_sql ); + if ( 1 !== $argument_count && 2 !== $argument_count ) { + return null; + } + $value = sprintf( 'CAST(%s AS double precision)', $argument_sql[ $argument_count - 1 ] ); + if ( 1 === $argument_count ) { + return sprintf( 'CASE WHEN %1$s IS NULL OR %1$s <= 0 THEN NULL ELSE LN(%1$s) END', $value ); + } + $base = sprintf( 'CAST(%s AS double precision)', $argument_sql[0] ); + return sprintf( 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %1$s <= 1 OR %2$s <= 0 THEN NULL ELSE LN(%2$s) / LN(%1$s) END', $base, $value ); + } + private function get_postgresql_mysql_substring_sql( array $argument_sql ): ?string { + if ( 2 !== count( $argument_sql ) && 3 !== count( $argument_sql ) ) { + return null; + } + $value = sprintf( 'CAST(%s AS text)', $argument_sql[0] ); + $position = sprintf( 'CAST(%s AS integer)', $argument_sql[1] ); + $start = sprintf( 'CASE WHEN %1$s > 0 THEN %1$s WHEN %1$s < 0 THEN CHAR_LENGTH(%2$s) + %1$s + 1 ELSE 0 END', $position, $value ); + if ( 2 === count( $argument_sql ) ) { + return sprintf( 'CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s = 0 OR (%3$s < 1) THEN \'\' ELSE SUBSTRING(%1$s FROM %3$s) END', $value, $position, $start ); + } + $length = sprintf( 'CAST(%s AS integer)', $argument_sql[2] ); + return sprintf( 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s IS NULL THEN NULL WHEN %2$s = 0 OR %3$s < 1 OR (%4$s < 1) THEN \'\' ELSE SUBSTRING(%1$s FROM %4$s FOR %3$s) END', $value, $position, $length, $start ); + } + private function get_mysql_sql_string_literal_value( string $sql ): ?string { + if ( strlen( $sql ) < 2 || "'" !== $sql[0] || "'" !== substr( $sql, -1 ) ) { + return null; + } + return str_replace( "''", "'", substr( $sql, 1, -1 ) ); + } + private function is_mysql_boolean_condition_expression( array $tokens, int $start, int $end ): bool { + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + continue; + } + if ( 0 !== $depth ) { + continue; + } + if ( + in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::BETWEEN_SYMBOL, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::IN_SYMBOL, + WP_MySQL_Lexer::IS_SYMBOL, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::LOGICAL_AND_OPERATOR, + WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SAFE_EQUAL_OPERATOR, + WP_MySQL_Lexer::REGEXP_SYMBOL, + ), + true + ) + ) { + return true; + } + } + return false; + } + private function get_postgresql_mysql_locate_with_position_sql( string $needle_sql, string $haystack_sql, string $position_sql ): string { + $needle = sprintf( 'CAST(%s AS text)', $needle_sql ); + $haystack = sprintf( 'CAST(%s AS text)', $haystack_sql ); + $position = sprintf( 'CAST(%s AS integer)', $position_sql ); + $offset = sprintf( 'STRPOS(SUBSTRING(%s FROM %s), %s)', $haystack, $position, $needle ); + return sprintf( 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s IS NULL THEN NULL WHEN %3$s < 1 THEN 0 WHEN %4$s = 0 THEN 0 ELSE %4$s + %3$s - 1 END', $needle, $haystack, $position, $offset ); + } + 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, + ); + } + 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; + } + 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; + } + return $this->get_postgresql_mysql_date_arithmetic_translation( $tokens, $bounds, $tokens[ $position ]->id ); + } + private function translate_mysql_infix_interval_expression_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_infix_interval_expression_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + return $this->get_postgresql_mysql_date_arithmetic_translation( $tokens, $bounds, $tokens[ $position ]->id ); + } + private function get_postgresql_mysql_date_arithmetic_translation( array $tokens, array $bounds, int $token_id ): array { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['interval_value_start'], + $bounds['interval_value_end'] + ); + $interval_sql = $bounds['interval_sql'] ?? $this->get_postgresql_mysql_interval_sql( $value_sql, $bounds['interval_unit'] ); + return $this->get_postgresql_mysql_expression_translation( + sprintf( + '(%1$s %2$s %3$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $bounds['operator'], + $interval_sql + ), + $token_id, + $bounds['close'] + ); + } + private function get_mysql_infix_interval_expression_bounds( array $tokens, int $position, int $end ): ?array { + if ( + isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + if ( $after_close === $end ) { + $inner = $this->get_mysql_infix_interval_expression_bounds( $tokens, $position + 1, $after_close - 1 ); + if ( null === $inner || $inner['close'] !== $after_close - 2 ) { + return null; + } + $inner['close'] = $after_close - 1; + return $inner; + } + } + $expression = $this->get_mysql_infix_interval_left_expression_bounds( $tokens, $position, $end ); + if ( null === $expression ) { + return null; + } + $operator_position = $expression['end']; + if ( + ! isset( $tokens[ $operator_position ] ) + || ! in_array( $tokens[ $operator_position ]->id, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) + ) { + return null; + } + $interval = $this->get_mysql_interval_argument_bounds( $tokens, $operator_position + 1, $end ); + if ( null === $interval ) { + return null; + } + $bounds = array( + 'operator' => WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $operator_position ]->id ? '-' : '+', + 'expression_start' => $expression['start'], + 'expression_end' => $expression['end'], + 'interval_value_start' => $interval['value_start'], + 'interval_value_end' => $interval['value_end'], + 'interval_unit' => $interval['unit'], + 'close' => $end - 1, + ); + if ( isset( $interval['sql'] ) ) { + $bounds['interval_sql'] = $interval['sql']; + } + return $bounds; + } + private function get_mysql_infix_interval_left_expression_bounds( array $tokens, int $position, int $end ): ?array { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + $inner = $this->get_mysql_infix_interval_left_expression_bounds( $tokens, $position + 1, $after_close - 1 ); + if ( null !== $inner && $inner['start'] === $position + 1 && $inner['end'] === $after_close - 1 ) { + return array( + 'start' => $position, + 'end' => $after_close, + ); + } + } + if ( WP_MySQL_Lexer::CASE_SYMBOL === $tokens[ $position ]->id ) { + $case_expression = $this->get_mysql_case_expression_descriptor( $tokens, $position, $end ); + if ( null !== $case_expression ) { + return array( + 'start' => $position, + 'end' => $case_expression['end'], + ); + } + } + $common_function = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null !== $common_function ) { + return array( + 'start' => $position, + 'end' => $common_function['close'] + 1, + ); + } + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) ) { + return array( + 'start' => $position, + 'end' => $position + 1, + ); + } + return null; + } + private function contains_unsupported_mysql_date_arithmetic_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_date_arithmetic_function_call_descriptor( $tokens, $i ) ) { + continue; + } + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $end ); + if ( null === $after_close || null === $this->get_mysql_date_arithmetic_function_bounds( $tokens, $i, $end ) ) { + return true; + } + } + return false; + } + private function contains_unsupported_mysql_date_arithmetic_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + $end = null === $statement_end ? count( $tokens ) : $statement_end; + return $this->contains_unsupported_mysql_date_arithmetic_function( $tokens, 0, $end ) + || $this->contains_unsupported_mysql_timestampadd_function( $tokens, 0, $end ) + || $this->contains_unsupported_mysql_translated_common_function( $tokens, 0, $end, 'timestampdiff', 'translate_mysql_timestampdiff_function_to_postgresql' ); + } + private function contains_unsupported_mysql_timestampadd_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + 'timestampadd' !== $this->get_mysql_common_function_name( $tokens[ $i ] ?? null ) + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $end ); + if ( null === $bounds ) { + return true; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( + null === $arguments + || 3 !== count( $arguments ) + || ! $this->is_supported_mysql_timestampadd_interval( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + $arguments[1]['start'], + $arguments[1]['end'] + ) + ) { + return true; + } + } + return false; + } + private function is_supported_mysql_timestampadd_interval( array $tokens, int $unit_start, int $unit_end, int $value_start, int $value_end ): bool { + if ( $unit_start + 1 !== $unit_end || ! isset( $tokens[ $unit_start ] ) ) { + return false; + } + if ( null !== $this->get_postgresql_simple_interval_unit( $tokens[ $unit_start ] ) ) { + return true; + } + $part_units = $this->get_mysql_composite_interval_part_units( $tokens[ $unit_start ] ); + if ( null === $part_units ) { + return false; + } + $value = $this->get_mysql_composite_interval_literal_value( $tokens, $value_start, $value_end ); + if ( null === $value ) { + return false; + } + return $value['is_null'] || null !== $this->parse_mysql_composite_interval_literal_components( $value['value'], $part_units ); + } + private function contains_unsupported_mysql_translated_common_function( array $tokens, int $start, int $end, string $function_name, string $translator_name ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + $function_name !== $this->get_mysql_common_function_name( $tokens[ $i ] ?? null ) + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $end ); + if ( null === $bounds ) { + return true; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || null === $this->$translator_name( $tokens, $arguments, $bounds['close'] ) ) { + return true; + } + } + return false; + } + private function get_mysql_date_arithmetic_function_bounds( array $tokens, int $position, int $end ): ?array { + $function = $this->get_mysql_date_arithmetic_function_call_descriptor( $tokens, $position ); + if ( null === $function ) { + 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 + && $function['permits_numeric_day_alias'] + ) { + $interval = array( + 'value_start' => $arguments[1]['start'], + 'value_end' => $arguments[1]['end'], + 'unit' => 'day', + ); + } + if ( null === $interval ) { + return null; + } + $bounds = array( + 'operator' => $function['operator'], + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'interval_value_start' => $interval['value_start'], + 'interval_value_end' => $interval['value_end'], + 'interval_unit' => $interval['unit'], + 'close' => $after_close - 1, + ); + if ( isset( $interval['sql'] ) ) { + $bounds['interval_sql'] = $interval['sql']; + } + return $bounds; + } + private function get_mysql_date_arithmetic_function_call_descriptor( array $tokens, int $position ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + $descriptors = array( + WP_MySQL_Lexer::ADDDATE_SYMBOL => '+1', + WP_MySQL_Lexer::DATE_ADD_SYMBOL => '+0', + WP_MySQL_Lexer::DATE_SUB_SYMBOL => '-0', + WP_MySQL_Lexer::SUBDATE_SYMBOL => '-1', + ); + $descriptor = $descriptors[ $tokens[ $position ]->id ] ?? null; + if ( null === $descriptor ) { + return null; + } + return array( + 'operator' => $descriptor[0], + 'permits_numeric_day_alias' => '1' === $descriptor[1], + ); + } + private function get_mysql_interval_argument_bounds( array $tokens, int $start, int $end ): ?array { + if ( + $start + 3 > $end + || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::INTERVAL_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + $unit_token = $tokens[ $end - 1 ]; + $unit = $this->get_postgresql_simple_interval_unit( $unit_token ); + if ( null !== $unit ) { + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => $unit, + ); + } + $part_units = $this->get_mysql_composite_interval_part_units( $unit_token ); + if ( null === $part_units ) { + return null; + } + $sql = $this->get_postgresql_mysql_composite_interval_literal_sql( $tokens, $start + 1, $end - 1, $part_units ); + if ( null === $sql ) { + return null; + } + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => 'composite', + 'sql' => $sql, + ); + } + private function get_postgresql_simple_interval_unit( WP_MySQL_Token $token ): ?string { + $units = array_combine( + array( WP_MySQL_Lexer::MICROSECOND_SYMBOL, WP_MySQL_Lexer::SECOND_SYMBOL, WP_MySQL_Lexer::MINUTE_SYMBOL, WP_MySQL_Lexer::HOUR_SYMBOL, WP_MySQL_Lexer::DAY_SYMBOL, WP_MySQL_Lexer::WEEK_SYMBOL, WP_MySQL_Lexer::MONTH_SYMBOL, WP_MySQL_Lexer::QUARTER_SYMBOL, WP_MySQL_Lexer::YEAR_SYMBOL ), + array( 'microsecond', 'second', 'minute', 'hour', 'day', 'week', 'month', '3 months', 'year' ) + ); + return $units[ $token->id ] ?? null; + } + private function get_mysql_composite_interval_part_units( WP_MySQL_Token $token ): ?array { + $units = array_combine( + array( WP_MySQL_Lexer::SECOND_MICROSECOND_SYMBOL, WP_MySQL_Lexer::MINUTE_SECOND_SYMBOL, WP_MySQL_Lexer::MINUTE_MICROSECOND_SYMBOL, WP_MySQL_Lexer::HOUR_MINUTE_SYMBOL, WP_MySQL_Lexer::HOUR_SECOND_SYMBOL, WP_MySQL_Lexer::HOUR_MICROSECOND_SYMBOL, WP_MySQL_Lexer::DAY_HOUR_SYMBOL, WP_MySQL_Lexer::DAY_MINUTE_SYMBOL, WP_MySQL_Lexer::DAY_SECOND_SYMBOL, WP_MySQL_Lexer::DAY_MICROSECOND_SYMBOL, WP_MySQL_Lexer::YEAR_MONTH_SYMBOL ), + array( 'second microsecond', 'minute second', 'minute second microsecond', 'hour minute', 'hour minute second', 'hour minute second microsecond', 'day hour', 'day hour minute', 'day hour minute second', 'day hour minute second microsecond', 'year month' ) + ); + return isset( $units[ $token->id ] ) ? explode( ' ', $units[ $token->id ] ) : null; + } + private function get_postgresql_mysql_composite_interval_literal_sql( array $tokens, int $start, int $end, array $part_units ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + $value = $this->get_mysql_composite_interval_literal_value( $tokens, $start, $end ); + if ( null === $value ) { + return null; + } + if ( $value['is_null'] ) { + return 'CAST(NULL AS interval)'; + } + $components = $this->parse_mysql_composite_interval_literal_components( $value['value'], $part_units ); + if ( null === $components ) { + return null; + } + return $this->get_postgresql_mysql_composite_interval_components_sql( $components ); + } + private function get_mysql_composite_interval_literal_value( array $tokens, int $start, int $end ): ?array { + if ( $start + 1 === $end ) { + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + return $this->get_mysql_composite_interval_literal_value_result( '', true ); + } + if ( $this->is_mysql_string_literal_token( $tokens[ $start ] ) ) { + return $this->get_mysql_composite_interval_literal_value_result( $tokens[ $start ]->get_value() ); + } + if ( $this->is_mysql_unsigned_numeric_token( $tokens[ $start ] ) ) { + return $this->get_mysql_composite_interval_literal_value_result( $tokens[ $start ]->get_value() ); + } + } + if ( + $start + 2 === $end + && isset( $tokens[ $start + 1 ] ) + && ( + WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + ) + && $this->is_mysql_unsigned_numeric_token( $tokens[ $start + 1 ] ) + ) { + return $this->get_mysql_composite_interval_literal_value_result( $tokens[ $start ]->get_bytes() . $tokens[ $start + 1 ]->get_value() ); + } + return null; + } + private function get_mysql_composite_interval_literal_value_result( string $value, bool $is_null = false ): array { + return array( + 'value' => $value, + 'is_null' => $is_null, + ); + } + private function is_mysql_unsigned_numeric_token( WP_MySQL_Token $token ): bool { + if ( $this->is_mysql_unsigned_integer_token( $token ) ) { + return true; + } + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::DECIMAL_NUMBER, + ), + true + ) + ) { + return 1 === preg_match( '/^[0-9]+[.][0-9]+$/', $token->get_value() ); + } + return false; + } + private function parse_mysql_composite_interval_literal_components( string $value, array $part_units ): ?array { + $part_count = count( $part_units ); + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + $sign = ''; + if ( '-' === $value[0] || '+' === $value[0] ) { + $sign = '-' === $value[0] ? '-' : ''; + $value = trim( substr( $value, 1 ) ); + } + if ( '' === $value || 1 !== preg_match( '/^[0-9]+(?:[^0-9]+[0-9]+)*$/', $value ) ) { + return null; + } + $parts = preg_split( '/[^0-9]+/', $value ); + if ( false === $parts || empty( $parts ) || count( $parts ) > $part_count ) { + return null; + } + $units = array_slice( $part_units, $part_count - count( $parts ) ); + $components = array(); + foreach ( $parts as $index => $part ) { + $unit = $units[ $index ]; + if ( 'microsecond' === $unit && strlen( $part ) > 6 ) { + return null; + } + $component_value = 'microsecond' === $unit ? str_pad( $part, 6, '0' ) : $part; + $components[] = array( + 'value' => $sign . $component_value, + 'unit' => $unit, + ); + } + return $components; + } + private function get_postgresql_mysql_composite_interval_components_sql( array $components ): string { + $parts = array(); + foreach ( $components as $component ) { + $parts[] = sprintf( + '(CAST(%1$s AS double precision) * INTERVAL %2$s)', + $this->connection->quote( $component['value'] ), + $this->connection->quote( '1 ' . $component['unit'] ) + ); + } + return '(' . implode( ' + ', $parts ) . ')'; + } + private function get_postgresql_mysql_interval_sql( string $value_sql, string $unit ): string { + $interval_unit = '3 months' === $unit ? $unit : '1 ' . $unit; + $value_cast_sql = 'second' === $unit + ? $this->get_postgresql_mysql_numeric_cast_sql( $value_sql ) + : $this->get_postgresql_mysql_integer_cast_sql( $value_sql ); + return sprintf( + '(%1$s * INTERVAL %2$s)', + sprintf( 'CAST(%s AS double precision)', $value_cast_sql ), + $this->connection->quote( $interval_unit ) + ); + } + private function translate_mysql_timestampadd_function_to_postgresql( array $tokens, array $arguments, int $close ): ?array { + if ( 3 !== count( $arguments ) ) { + return null; + } + $interval = $this->get_mysql_timestampadd_interval( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + $arguments[1]['start'], + $arguments[1]['end'] + ); + if ( null === $interval ) { + return null; + } + $interval_sql = $interval['sql'] ?? null; + if ( null === $interval_sql ) { + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'] + ); + $interval_sql = $this->get_postgresql_mysql_interval_sql( $value_sql, $interval['unit'] ); + } + $datetime_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'] + ); + return $this->get_postgresql_mysql_expression_translation( + sprintf( + '(%1$s + %2$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $datetime_sql ), + $interval_sql + ), + WP_MySQL_Lexer::IDENTIFIER, + $close + ); + } + private function translate_mysql_timestampdiff_function_to_postgresql( array $tokens, array $arguments, int $close ): ?array { + if ( 3 !== count( $arguments ) ) { + return null; + } + $unit_start = $arguments[0]['start']; + $unit_end = $arguments[0]['end']; + if ( $unit_start + 1 !== $unit_end || ! isset( $tokens[ $unit_start ] ) ) { + return null; + } + $unit = $this->get_postgresql_simple_interval_unit( $tokens[ $unit_start ] ); + $unit = '3 months' === $unit ? 'quarter' : $unit; + if ( null === $unit ) { + return null; + } + $start_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'] + ); + $end_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'] + ); + return $this->get_postgresql_mysql_expression_translation( + $this->get_postgresql_mysql_timestampdiff_sql( $unit, $start_sql, $end_sql ), + WP_MySQL_Lexer::IDENTIFIER, + $close + ); + } + private function get_postgresql_mysql_timestampdiff_sql( string $unit, string $start_sql, string $end_sql ): string { + $start_timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $start_sql ); + $end_timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $end_sql ); + $epoch_delta_sql = 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) %3$s) AS bigint)'; + if ( 'microsecond' === $unit ) { + return sprintf( $epoch_delta_sql, $start_timestamp_sql, $end_timestamp_sql, '* 1000000' ); + } + $seconds_per_unit = array_combine( explode( ' ', 'second minute hour day week' ), array( 1, 60, 3600, 86400, 604800 ) ); + if ( isset( $seconds_per_unit[ $unit ] ) ) { + return sprintf( $epoch_delta_sql, $start_timestamp_sql, $end_timestamp_sql, '/ ' . $seconds_per_unit[ $unit ] ); + } + $month_delta_sql = sprintf( + '((CAST(EXTRACT(YEAR FROM %2$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %2$s) AS integer)) - (CAST(EXTRACT(YEAR FROM %1$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %1$s) AS integer)))', + $start_timestamp_sql, + $end_timestamp_sql + ); + $start_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $start_timestamp_sql ); + $end_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $end_timestamp_sql ); + $month_sql = sprintf( + 'CAST(CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s >= %1$s THEN (%3$s - CASE WHEN %4$s < %5$s THEN 1 ELSE 0 END) ELSE (%3$s + CASE WHEN %4$s > %5$s THEN 1 ELSE 0 END) END AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $month_delta_sql, + $end_remainder_sql, + $start_remainder_sql + ); + if ( 'month' === $unit ) { + return $month_sql; + } + if ( 'quarter' === $unit ) { + return sprintf( 'CAST(TRUNC((%s)::numeric / 3) AS bigint)', $month_sql ); + } + return sprintf( 'CAST(TRUNC((%s)::numeric / 12) AS bigint)', $month_sql ); + } + private function get_mysql_timestampadd_interval( array $tokens, int $unit_start, int $unit_end, int $value_start, int $value_end ): ?array { + if ( $unit_start + 1 !== $unit_end || ! isset( $tokens[ $unit_start ] ) ) { + return null; + } + $unit = $this->get_postgresql_simple_interval_unit( $tokens[ $unit_start ] ); + if ( null !== $unit ) { + return array( + 'unit' => $unit, + ); + } + $part_units = $this->get_mysql_composite_interval_part_units( $tokens[ $unit_start ] ); + if ( null === $part_units ) { + return null; + } + $sql = $this->get_postgresql_mysql_composite_interval_literal_sql( $tokens, $value_start, $value_end, $part_units ); + if ( null === $sql ) { + return null; + } + return array( + 'unit' => 'composite', + 'sql' => $sql, + ); + } + 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'] + ); + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + return $this->get_postgresql_mysql_expression_translation( + $this->get_postgresql_mysql_week_timestamp_sql( $timestamp_sql, $bounds['mode'] ), + WP_MySQL_Lexer::CASE_SYMBOL, + $bounds['close'] + ); + } + private function get_mysql_week_function_bounds( array $tokens, int $position, int $end ): ?array { + $function_name = $this->get_mysql_week_function_call_name( $tokens, $position ); + if ( null === $function_name ) { + return null; + } + $is_weekofyear = 'weekofyear' === $function_name; + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $arguments || ! in_array( count( $arguments ), array( 1, 2 ), true ) ) { + return null; + } + if ( $is_weekofyear && 1 !== count( $arguments ) ) { + return null; + } + $mode = $is_weekofyear ? 3 : 0; + if ( 2 === count( $arguments ) ) { + $mode_start = $arguments[1]['start']; + $mode_end = $arguments[1]['end']; + if ( + $mode_start + 1 !== $mode_end + || ! isset( $tokens[ $mode_start ] ) + || WP_MySQL_Lexer::INT_NUMBER !== $tokens[ $mode_start ]->id + ) { + return null; + } + $mode_value = $tokens[ $mode_start ]->get_value(); + if ( ! in_array( $mode_value, array( '0', '1', '2', '3', '4', '5', '6', '7' ), true ) ) { + return null; + } + $mode = (int) $mode_value; + } + return array( + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'mode' => $mode, + 'close' => $after_close - 1, + ); + } + private function contains_unsupported_mysql_week_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_week_function_call_name( $tokens, $i ) ) { + continue; + } + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $end ); + if ( null === $after_close || null === $this->get_mysql_week_function_bounds( $tokens, $i, $end ) ) { + return true; + } + } + return false; + } + private function get_mysql_week_function_call_name( array $tokens, int $position ): ?string { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + if ( WP_MySQL_Lexer::WEEK_SYMBOL === $tokens[ $position ]->id ) { + return 'week'; + } + $function_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); + return null !== $function_name && 0 === strcasecmp( $function_name, 'weekofyear' ) ? 'weekofyear' : null; + } + private function contains_unsupported_mysql_week_function_query( string $query ): bool { + return $this->contains_unsupported_mysql_range_scanner_query( $query, array( 'contains_unsupported_mysql_week_function' ) ); + } + private function get_postgresql_mysql_week_timestamp_sql( string $timestamp_sql, int $mode ): string { + if ( 3 === $mode ) { + return sprintf( + 'CAST(TO_CHAR(%s, %s) AS integer)', + $timestamp_sql, + $this->connection->quote( 'IW' ) + ); + } + $descriptors = array_combine( + array( 0, 1, 2, 4, 5, 6, 7 ), + explode( '|', 'zero:sunday:first-sunday|zero:monday:first-monday-four|one:sunday:first-sunday|zero:sunday:first-sunday-four|zero:monday:first-monday|one-wrap:sunday:first-sunday-four|one:monday:first-monday' ) + ); + if ( isset( $descriptors[ $mode ] ) ) { + list( $style, $week_start, $first_week_start ) = explode( ':', $descriptors[ $mode ] ); + + $first_week_start_formats = array( + 'first-sunday' => "(%1\$s + (MOD(7 - CAST(EXTRACT(DOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + 'first-sunday-four' => "(CASE WHEN EXTRACT(DOW FROM %1\$s) <= 3 THEN %2\$s ELSE %2\$s + INTERVAL '1 week' END)", + 'first-monday' => "(%1\$s + (MOD(8 - CAST(EXTRACT(ISODOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + 'first-monday-four' => "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + ); + if ( ! isset( $first_week_start_formats[ $first_week_start ] ) ) { + throw new LogicException( 'Unsupported MySQL week descriptor.' ); + } + $get_first_week_start_sql = function ( string $year_start_sql ) use ( $first_week_start_formats, $first_week_start ): string { + return sprintf( $first_week_start_formats[ $first_week_start ], $year_start_sql, $this->get_postgresql_mysql_sunday_week_start_sql( $year_start_sql ) ); + }; + + $week_start_sql = 'sunday' === $week_start ? $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ) : sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $get_first_week_start_sql( $year_start_sql ); + if ( 'zero' === $style ) { + 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 + ); + } + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $get_first_week_start_sql( $previous_year_start_sql ); + $next_first_week_sql = 'one-wrap' === $style ? $get_first_week_start_sql( sprintf( "(%s + INTERVAL '1 year')", $year_start_sql ) ) : null; + $previous_week_index_sql = sprintf( 'CAST(FLOOR(EXTRACT(EPOCH FROM (%1$s - %2$s)) / 604800) AS integer) + 1', $week_start_sql, $previous_first_week_sql ); + $current_week_index_sql = sprintf( 'CAST(FLOOR(EXTRACT(EPOCH FROM (%1$s - %2$s)) / 604800) AS integer) + 1', $week_start_sql, $first_week_start_sql ); + return null === $next_first_week_sql + ? sprintf( 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN %4$s ELSE %5$s END', $timestamp_sql, $week_start_sql, $first_week_start_sql, $previous_week_index_sql, $current_week_index_sql ) + : sprintf( 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s >= %3$s THEN 1 WHEN %2$s < %4$s THEN %5$s ELSE %6$s END', $timestamp_sql, $week_start_sql, $next_first_week_sql, $first_week_start_sql, $previous_week_index_sql, $current_week_index_sql ); + } + throw new InvalidArgumentException( 'Unsupported MySQL WEEK() mode.' ); + } + 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'] + ); + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $sql = 'dayofweek' === $bounds['function'] + ? sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ) + : sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + return $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::CAST_SYMBOL, $bounds['close'] ); + } + 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'], + ); + } + private function translate_mysql_date_format_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + if ( $this->is_mysql_null_literal_expression( $tokens, $bounds['expression_start'], $bounds['expression_end'] ) ) { + $sql = 'NULL'; + } elseif ( null !== $bounds['format'] ) { + $sql = $this->get_postgresql_mysql_date_format_sql( $bounds['format'], $expression_sql ); + } elseif ( $bounds['format_is_null'] ) { + $sql = 'NULL'; + } else { + $sql = $this->get_postgresql_mysql_finite_date_format_choice_sql( + $tokens, + $bounds['format_start'], + $bounds['format_end'], + $expression_sql, + false + ); + if ( null === $sql ) { + $format_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['format_start'], + $bounds['format_end'] + ); + $sql = $this->get_postgresql_mysql_dynamic_date_format_sql( $format_sql, $expression_sql ); + } + } + if ( null === $sql ) { + return null; + } + return $this->get_postgresql_mysql_expression_translation( $sql, WP_MySQL_Lexer::CASE_SYMBOL, $bounds['close'] ); + } + private function get_postgresql_mysql_finite_date_format_choice_sql( array $tokens, int $start, int $end, string $expression_sql, bool $force_string ): ?string { + $if_sql = $this->get_postgresql_mysql_finite_date_format_if_choice_sql( + $tokens, + $start, + $end, + $expression_sql, + $force_string + ); + if ( null !== $if_sql ) { + return $if_sql; + } + $case_expression = $this->get_mysql_case_expression_descriptor( $tokens, $start, $end ); + if ( null === $case_expression || $case_expression['end'] !== $end ) { + return null; + } + return $this->get_postgresql_mysql_finite_date_format_case_choice_sql( $tokens, $case_expression, $expression_sql, $force_string ); + } + private function get_postgresql_mysql_finite_date_format_if_choice_sql( array $tokens, int $start, int $end, string $expression_sql, bool $force_string ): ?string { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || $bounds['close'] + 1 !== $end || 'if' !== $bounds['function'] ) { + return null; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 3 !== count( $arguments ) ) { + return null; + } + $truthy_sql = $this->get_postgresql_mysql_date_format_constant_branch_sql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'], + $expression_sql, + $force_string + ); + $falsy_sql = $this->get_postgresql_mysql_date_format_constant_branch_sql( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'], + $expression_sql, + $force_string + ); + if ( null === $truthy_sql || null === $falsy_sql ) { + return null; + } + $condition_sql = $this->get_postgresql_mysql_date_format_condition_sql( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + return sprintf( 'CASE WHEN %s THEN %s ELSE %s END', $condition_sql, $truthy_sql, $falsy_sql ); + } + private function get_postgresql_mysql_finite_date_format_case_choice_sql( array $tokens, array $case_expression, string $expression_sql, bool $force_string ): ?string { + $value_sql = $case_expression['simple'] + ? $this->translate_mysql_token_sequence_to_postgresql( $tokens, $case_expression['value_start'], $case_expression['value_end'] ) + : null; + $parts = array( 'CASE' ); + foreach ( $case_expression['branches'] as $branch ) { + $branch_sql = $this->get_postgresql_mysql_date_format_constant_branch_sql( + $tokens, + $branch['result_start'], + $branch['result_end'], + $expression_sql, + $force_string + ); + if ( null === $branch_sql ) { + return null; + } + if ( ! $case_expression['simple'] ) { + $condition_sql = $this->get_postgresql_mysql_date_format_condition_sql( $tokens, $branch['test_start'], $branch['test_end'] ); + } else { + $condition_sql = sprintf( + '(%s = %s)', + $value_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $branch['test_start'], $branch['test_end'] ) + ); + } + $parts[] = sprintf( 'WHEN %s THEN %s', $condition_sql, $branch_sql ); + } + $else_sql = 'NULL'; + if ( null !== $case_expression['else'] ) { + $else_sql = $this->get_postgresql_mysql_date_format_constant_branch_sql( + $tokens, + $case_expression['else']['start'], + $case_expression['else']['end'], + $expression_sql, + $force_string + ); + if ( null === $else_sql ) { + return null; + } + } + $parts[] = 'ELSE ' . $else_sql; + $parts[] = 'END'; + return implode( ' ', $parts ); + } + private function get_postgresql_mysql_date_format_condition_sql( array $tokens, int $start, int $end ): string { + $condition_argument_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + return $this->is_mysql_boolean_condition_expression( $tokens, $start, $end ) + ? '(' . $condition_argument_sql . ')' + : sprintf( 'COALESCE(%s <> 0, false)', $this->get_postgresql_mysql_numeric_cast_sql( $condition_argument_sql ) ); + } + private function get_postgresql_mysql_date_format_constant_branch_sql( array $tokens, int $start, int $end, string $expression_sql, bool $force_string ): ?string { + $format = $this->get_mysql_constant_string_expression_value( $tokens, $start, $end ); + if ( null === $format ) { + return null; + } + if ( $format['is_null'] ) { + return 'NULL'; + } + return $force_string + ? $this->get_postgresql_mysql_generic_date_format_sql( $format['value'], $expression_sql, false ) + : $this->get_postgresql_mysql_date_format_sql( $format['value'], $expression_sql ); + } + private function contains_unsupported_mysql_date_format_function( array $tokens, int $start, int $end ): bool { + return $this->contains_unsupported_mysql_rewrite_range( $tokens, $start, $end, array( 'get_mysql_function_call_bounds', array( 'date_format' ) ), array( 'get_mysql_date_format_call_bounds' ) ); + } + private function contains_unsupported_mysql_rewrite_range( array $tokens, int $start, int $end, array $marker, array $validator ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->{$marker[0]}( $tokens, $i, $end, ...( $marker[1] ?? array() ) ) ) { + continue; + } + if ( null === $this->{$validator[0]}( $tokens, $i, $end, ...( $validator[1] ?? array() ) ) ) { + return true; + } + } + return false; + } + private function contains_unsupported_mysql_range_scanner_query( string $query, array $scanner_names ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + $end = null === $statement_end ? count( $tokens ) : $statement_end; + foreach ( $scanner_names as $scanner_name ) { + if ( $this->$scanner_name( $tokens, 0, $end ) ) { + return true; + } + } + return false; + } + private function get_mysql_date_format_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $position, $end ); + if ( null === $bounds || null === $bounds['format'] ) { + return null; + } + return $bounds; + } + private function get_mysql_date_format_call_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'date_format' ); + if ( null === $bounds ) { + return null; + } + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + $format_constant = $this->get_mysql_constant_string_argument_value( $tokens, $arguments[1] ); + $format = null !== $format_constant && ! $format_constant['is_null'] ? $format_constant['value'] : null; + return array( + 'format' => $format, + 'format_is_null' => null !== $format_constant && $format_constant['is_null'], + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'format_start' => $arguments[1]['start'], + 'format_end' => $arguments[1]['end'], + 'close' => $bounds['close'], + ); + } + private function get_postgresql_mysql_date_format_sql( string $format, string $expression_sql ): ?string { + $numeric_formats = array( + '%H.%i' => array( "SUBSTRING(%1\$s FROM 12 FOR 2) || '.' || SUBSTRING(%1\$s FROM 15 FOR 2)", 'TO_CHAR(%1$s, %2$s)', 'HH24.MI' ), + '%H.%i%s' => array( "SUBSTRING(%1\$s FROM 12 FOR 2) || '.' || SUBSTRING(%1\$s FROM 15 FOR 2) || SUBSTRING(%1\$s FROM 18 FOR 2)", 'TO_CHAR(%1$s, %2$s)', 'HH24.MISS' ), + '0.%i%s' => array( "'0.' || SUBSTRING(%1\$s FROM 15 FOR 2) || SUBSTRING(%1\$s FROM 18 FOR 2)", "'0.' || TO_CHAR(%1\$s, %2\$s)", 'MISS' ), + ); + if ( isset( $numeric_formats[ $format ] ) ) { + $numeric_format = $numeric_formats[ $format ]; + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(%3$s AS double precision) ELSE 0 END', + $expression_text_sql, + self::POSTGRESQL_MYSQL_DATE_TIME_TEXT_PATTERN, + sprintf( $numeric_format[0], $expression_text_sql ) + ); + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(%3$s AS double precision) END', + $zero_date_condition, + $zero_date_format_sql, + sprintf( + $numeric_format[1], + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( $numeric_format[2] ) + ) + ); + } + if ( '%Y-%m-%d' === $format ) { + return $this->get_postgresql_mysql_ymd_format_sql( $expression_sql ); + } + return $this->get_postgresql_mysql_generic_date_format_sql( $format, $expression_sql ); + } + private function get_postgresql_mysql_generic_date_format_sql( string $format, string $expression_sql, bool $preserve_zero_date_parts = true ): ?string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $zero_date_format_sql = $this->get_postgresql_mysql_date_format_fragments_sql( + $format, + function ( string $specifier ) use ( $expression_text_sql ): ?string { + return $this->get_postgresql_mysql_zero_date_format_specifier_sql( $specifier, $expression_text_sql ); + }, + true + ); + $zero_date_format_sql = null === $zero_date_format_sql ? 'NULL' : $zero_date_format_sql; + $formatted_sql = $this->get_postgresql_mysql_date_format_fragments_sql( + $format, + function ( string $specifier ) use ( $timestamp_sql ): ?string { + return $this->get_postgresql_mysql_date_format_specifier_sql( $specifier, $timestamp_sql ); + } + ); + if ( ! $preserve_zero_date_parts ) { + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s OR %3$s THEN NULL ELSE %4$s END', + $expression_text_sql, + $empty_date_condition, + $zero_date_condition, + $formatted_sql + ); + } + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s THEN NULL WHEN %3$s THEN %4$s ELSE %5$s END', + $expression_text_sql, + $empty_date_condition, + $zero_date_condition, + $zero_date_format_sql, + $formatted_sql + ); + } + private function get_postgresql_mysql_date_format_fragments_sql( string $format, callable $specifier_callback, bool $null_on_known_unrenderable = false ): ?string { + $fragments = array(); + $literal = ''; + $length = strlen( $format ); + for ( $i = 0; $i < $length; $i++ ) { + if ( '%' !== $format[ $i ] ) { + $literal .= $format[ $i ]; + continue; + } + if ( $i + 1 >= $length ) { + $literal .= '%'; + continue; + } + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + $literal = ''; + } + $fragment = $specifier_callback( $format[ ++$i ] ); + if ( null === $fragment ) { + if ( $null_on_known_unrenderable && null !== $this->get_postgresql_mysql_date_format_specifier_sql( $format[ $i ], 'NULL' ) ) { + return null; + } + $literal .= $format[ $i ]; + continue; + } + $fragments[] = $fragment; + } + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + } + return empty( $fragments ) ? "''" : implode( ' || ', $fragments ); + } + private function get_postgresql_mysql_zero_date_format_specifier_sql( string $specifier, string $expression_text_sql ): ?string { + $descriptors = array_combine( + str_split( 'YymdceHiSs%DkhIlTrpf' ), + explode( '|', 'part:1:4|part:3:2|part:6:2|part:9:2|part-int:6:2|part-int:9:2|time:12|time:15|time:18|time:18|percent|day-suffix|time-int:12|hour12-pad|hour12-pad|hour12|time24|time12|meridiem|microsecond' ) + ); + if ( ! isset( $descriptors[ $specifier ] ) ) { + return null; + } + $descriptor = explode( ':', $descriptors[ $specifier ] ); + if ( 'part' === $descriptor[0] || 'part-int' === $descriptor[0] ) { + $part_sql = sprintf( 'SUBSTRING(%s FROM %d FOR %d)', $expression_text_sql, (int) $descriptor[1], (int) $descriptor[2] ); + return 'part-int' === $descriptor[0] ? sprintf( 'CAST(CAST(%s AS integer) AS text)', $part_sql ) : $part_sql; + } + if ( 'time' === $descriptor[0] ) { + return $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, (int) $descriptor[1], 2 ); + } + if ( 'percent' === $descriptor[0] ) { + return $this->connection->quote( '%' ); + } + if ( 'day-suffix' === $descriptor[0] ) { + return sprintf( 'CAST(%1$s AS text) || CASE WHEN %1$s %% 100 BETWEEN 11 AND 13 THEN \'th\' WHEN %1$s %% 10 = 1 THEN \'st\' WHEN %1$s %% 10 = 2 THEN \'nd\' WHEN %1$s %% 10 = 3 THEN \'rd\' ELSE \'th\' END', sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ) ); + } + if ( 'time-int' === $descriptor[0] ) { + return sprintf( 'CAST(CAST(%s AS integer) AS text)', $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, (int) $descriptor[1], 2 ) ); + } + $hour_12_sql = sprintf( 'MOD(CAST(%s AS integer) + 11, 12) + 1', $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) ); + if ( 'hour12-pad' === $descriptor[0] || 'hour12' === $descriptor[0] ) { + return sprintf( 'hour12-pad' === $descriptor[0] ? "LPAD(CAST(%s AS text), 2, '0')" : 'CAST(%s AS text)', $hour_12_sql ); + } + if ( 'time24' === $descriptor[0] || 'time12' === $descriptor[0] ) { + $time_sql = sprintf( + "%1\$s || ':' || %2\$s || ':' || %3\$s", + 'time24' === $descriptor[0] ? $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) : sprintf( "LPAD(CAST(%s AS text), 2, '0')", $hour_12_sql ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 15, 2 ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 18, 2 ) + ); + return 'time24' === $descriptor[0] ? $time_sql : sprintf( "%s || ' ' || CASE WHEN CAST(%s AS integer) < 12 THEN 'AM' ELSE 'PM' END", $time_sql, $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) ); + } + if ( 'meridiem' === $descriptor[0] ) { + return sprintf( "CASE WHEN CAST(%s AS integer) < 12 THEN 'AM' ELSE 'PM' END", $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) ); + } + return sprintf( "CASE WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING(%1\$s FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", $expression_text_sql ); + } + private function get_postgresql_mysql_zero_date_time_part_sql( string $expression_text_sql, int $start, int $length ): string { + return sprintf( "CASE WHEN %1\$s ~ %4\$s THEN SUBSTRING(%1\$s FROM %2\$d FOR %3\$d) ELSE '00' END", $expression_text_sql, $start, $length, self::POSTGRESQL_MYSQL_DATE_TIME_TEXT_PATTERN ); + } + private function get_postgresql_mysql_dynamic_date_format_sql( string $format_sql, string $expression_sql ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $format_text_sql = sprintf( 'CAST(%s AS text)', $format_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $character_sql = sprintf( + 'SUBSTRING(%s FROM "__wp_pg_mysql_date_format"."position" FOR 1)', + $format_text_sql + ); + $specifier_sql = sprintf( + 'SUBSTRING(%s FROM "__wp_pg_mysql_date_format"."position" + 1 FOR 1)', + $format_text_sql + ); + $percent_sql = $this->connection->quote( '%' ); + $next_position_sql = sprintf( + 'CASE WHEN %1$s = %2$s AND "__wp_pg_mysql_date_format"."position" < CHAR_LENGTH(%3$s) THEN "__wp_pg_mysql_date_format"."position" + 2 ELSE "__wp_pg_mysql_date_format"."position" + 1 END', + $character_sql, + $percent_sql, + $format_text_sql + ); + $fragment_sql = sprintf( 'CASE WHEN %1$s <> %2$s THEN %1$s WHEN "__wp_pg_mysql_date_format"."position" >= CHAR_LENGTH(%3$s) THEN %2$s ELSE %4$s END', $character_sql, $percent_sql, $format_text_sql, $this->get_postgresql_mysql_dynamic_date_format_specifier_case_sql( $specifier_sql, $timestamp_sql ) ); + $zero_date_fragment_sql = sprintf( 'CASE WHEN %1$s <> %2$s THEN %1$s WHEN "__wp_pg_mysql_date_format"."position" >= CHAR_LENGTH(%3$s) THEN %2$s ELSE %4$s END', $character_sql, $percent_sql, $format_text_sql, $this->get_postgresql_mysql_dynamic_date_format_specifier_case_sql( $specifier_sql, $expression_text_sql, true ) ); + $formatter_sql = sprintf( '(WITH RECURSIVE "__wp_pg_mysql_date_format"("position", "formatted") AS (SELECT 1, CAST(\'\' AS text) UNION ALL SELECT %1$s, "formatted" || %2$s FROM "__wp_pg_mysql_date_format" WHERE "position" <= CHAR_LENGTH(%3$s)) SELECT "formatted" FROM "__wp_pg_mysql_date_format" ORDER BY "position" DESC LIMIT 1)', $next_position_sql, $fragment_sql, $format_text_sql ); + $zero_date_formatter_sql = sprintf( '(WITH RECURSIVE "__wp_pg_mysql_date_format"("position", "formatted") AS (SELECT 1, CAST(\'\' AS text) UNION ALL SELECT %1$s, "formatted" || %2$s FROM "__wp_pg_mysql_date_format" WHERE "position" <= CHAR_LENGTH(%3$s)) SELECT "formatted" FROM "__wp_pg_mysql_date_format" ORDER BY "position" DESC LIMIT 1)', $next_position_sql, $zero_date_fragment_sql, $format_text_sql ); + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s THEN NULL WHEN %4$s THEN %5$s ELSE %6$s END', + $expression_text_sql, + $format_text_sql, + $empty_date_condition, + $zero_date_condition, + $zero_date_formatter_sql, + $formatter_sql + ); + } + private function get_postgresql_mysql_dynamic_date_format_specifier_case_sql( string $specifier_sql, string $expression_sql, bool $zero_date = false ): string { + $cases = array(); + if ( $zero_date ) { + $zero_date_part_specifiers = str_split( '%YymcdeDHkhIliSsTrpf' ); + foreach ( $zero_date_part_specifiers as $specifier ) { + $cases[] = sprintf( 'WHEN %s THEN %s', $this->connection->quote( $specifier ), $this->get_postgresql_mysql_zero_date_format_specifier_sql( $specifier, $expression_sql ) ); + } + foreach ( array_diff( array_merge( array_keys( $this->get_postgresql_mysql_date_format_to_char_formats() ), str_split( '%DwUuVvXx' ) ), $zero_date_part_specifiers ) as $specifier ) { + $cases[] = sprintf( 'WHEN %s THEN NULL', $this->connection->quote( $specifier ) ); + } + return sprintf( 'CASE %1$s %2$s ELSE %1$s END', $specifier_sql, implode( ' ', $cases ) ); + } + foreach ( array_merge( array_keys( $this->get_postgresql_mysql_date_format_to_char_formats() ), str_split( '%DwUuVvXx' ) ) as $specifier ) { + $cases[] = sprintf( + 'WHEN %s THEN %s', + $this->connection->quote( $specifier ), + $this->get_postgresql_mysql_date_format_specifier_sql( $specifier, $expression_sql ) + ); + } + return sprintf( 'CASE %1$s %2$s ELSE %1$s END', $specifier_sql, implode( ' ', $cases ) ); + } + private function get_postgresql_mysql_date_format_specifier_sql( string $specifier, string $timestamp_sql ): ?string { + $to_char_formats = $this->get_postgresql_mysql_date_format_to_char_formats(); + if ( isset( $to_char_formats[ $specifier ] ) ) { + return sprintf( 'TO_CHAR(%s, %s)', $timestamp_sql, $this->connection->quote( $to_char_formats[ $specifier ] ) ); + } + $special_specifiers = array_combine( + str_split( '%DwUuVvXx' ), + explode( '|', 'percent|day-suffix|dow|week:0|week:1|week:2|to-char:IW|sunday-week-year|to-char:IYYY' ) + ); + if ( ! isset( $special_specifiers[ $specifier ] ) ) { + return null; + } + $descriptor = $special_specifiers[ $specifier ]; + if ( 'percent' === $descriptor ) { + return $this->connection->quote( '%' ); + } + if ( 'day-suffix' === $descriptor ) { + return sprintf( 'CAST(%1$s AS text) || CASE WHEN %1$s %% 100 BETWEEN 11 AND 13 THEN \'th\' WHEN %1$s %% 10 = 1 THEN \'st\' WHEN %1$s %% 10 = 2 THEN \'nd\' WHEN %1$s %% 10 = 3 THEN \'rd\' ELSE \'th\' END', sprintf( 'CAST(EXTRACT(DAY FROM %s) AS integer)', $timestamp_sql ) ); + } + if ( 'dow' === $descriptor ) { + return sprintf( 'CAST(CAST(EXTRACT(DOW FROM %s) AS integer) AS text)', $timestamp_sql ); + } + if ( 0 === strpos( $descriptor, 'week:' ) ) { + return sprintf( + "LPAD(CAST(%s AS text), 2, '0')", + $this->get_postgresql_mysql_week_timestamp_sql( $timestamp_sql, (int) substr( $descriptor, 5 ) ) + ); + } + if ( 0 === strpos( $descriptor, 'to-char:' ) ) { + return sprintf( 'TO_CHAR(%s, %s)', $timestamp_sql, $this->connection->quote( substr( $descriptor, 8 ) ) ); + } + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( "(%1\$s + (MOD(7 - CAST(EXTRACT(DOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", $year_start_sql ); + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %2\$s < %3\$s THEN TO_CHAR(%4\$s - INTERVAL '1 year', 'YYYY') ELSE TO_CHAR(%4\$s, 'YYYY') END", + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $year_start_sql + ); + } + private function get_postgresql_mysql_date_format_to_char_formats(): array { + return array_combine( str_split( 'abcdefHhIijklMmprSsTWYy' ), explode( '|', 'Dy|Mon|FMMM|DD|FMDD|US|HH24|HH12|HH12|MI|DDD|FMHH24|FMHH12|FMMonth|MM|AM|HH12:MI:SS AM|SS|SS|HH24:MI:SS|FMDay|YYYY|YY' ) ); + } + private function get_postgresql_mysql_sunday_week_start_sql( string $timestamp_sql ): string { + return sprintf( "(DATE_TRUNC('day', %1\$s) - (CAST(EXTRACT(DOW FROM %1\$s) AS integer) * INTERVAL '1 day'))", $timestamp_sql ); + } + private function contains_unsupported_mysql_extract_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + ! isset( $tokens[ $i ], $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::EXTRACT_SYMBOL !== $tokens[ $i ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $end ); + if ( null === $after_close || null === $this->get_mysql_extract_function_bounds( $tokens, $i, $end ) ) { + return true; + } + } + return false; + } + private function contains_unsupported_mysql_extract_function_query( string $query ): bool { + return $this->contains_unsupported_mysql_range_scanner_query( $query, array( 'contains_unsupported_mysql_extract_function' ) ); + } + 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 $this->get_postgresql_mysql_expression_translation( + $this->get_postgresql_zero_date_safe_extract_sql( $bounds['unit'], $expression_sql ), + $tokens[ $position ]->id, + $bounds['close'] + ); + } + private function get_postgresql_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + if ( 'MICROSECOND' === $unit ) { + return sprintf( + "CASE WHEN %1\$s THEN NULL WHEN %2\$s THEN CAST(%3\$s AS integer) ELSE CAST(TO_CHAR(%4\$s, 'US') AS integer) END", + $empty_date_condition, + $zero_date_condition, + sprintf( "CASE WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING(%1\$s FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", $expression_text_sql ), + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + if ( 'DOY' === $unit ) { + $zero_date_extract_part_sql = 'NULL'; + } else { + $date_part_starts = array_combine( explode( '|', 'YEAR|MONTH|DAY' ), explode( '|', '1:4|6:2|9:2' ) ); + if ( isset( $date_part_starts[ $unit ] ) ) { + list( $start, $length ) = explode( ':', $date_part_starts[ $unit ] ); + $zero_date_extract_part_sql = sprintf( 'CAST(SUBSTRING(%s FROM %d FOR %d) AS integer)', $expression_text_sql, (int) $start, (int) $length ); + } elseif ( 'QUARTER' === $unit ) { + $zero_date_extract_part_sql = sprintf( 'CAST(FLOOR((CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer) + 2) / 3.0) AS integer)', $expression_text_sql ); + } else { + $time_part_starts = array_combine( explode( '|', 'HOUR|MINUTE|SECOND' ), array( 12, 15, 18 ) ); + if ( ! isset( $time_part_starts[ $unit ] ) ) { + $zero_date_extract_part_sql = sprintf( 'CAST(EXTRACT(%s FROM CAST(%s AS timestamp)) AS integer)', $unit, $expression_text_sql ); + } else { + $zero_date_extract_part_sql = 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, + self::POSTGRESQL_MYSQL_DATE_TIME_TEXT_PATTERN, + $time_part_starts[ $unit ] + ); + } + } + } + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN %3$s ELSE CAST(EXTRACT(%4$s FROM %5$s) AS integer) END', + $empty_date_condition, + $zero_date_condition, + $zero_date_extract_part_sql, + $unit, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + private function get_postgresql_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + return sprintf( + 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', + $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + private function get_postgresql_empty_temporal_condition_sql( string $expression_text_sql ): string { + return sprintf( "%s = ''", $expression_text_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 + ); + } + 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 ) { + 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, + ); + } + + $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, + ); + } + private function get_mysql_date_time_extract_unit( WP_MySQL_Token $token ): ?string { + $units = array_combine( + array( WP_MySQL_Lexer::YEAR_SYMBOL, WP_MySQL_Lexer::MONTH_SYMBOL, WP_MySQL_Lexer::QUARTER_SYMBOL, WP_MySQL_Lexer::DAY_SYMBOL, WP_MySQL_Lexer::DAYOFMONTH_SYMBOL, WP_MySQL_Lexer::HOUR_SYMBOL, WP_MySQL_Lexer::MINUTE_SYMBOL, WP_MySQL_Lexer::SECOND_SYMBOL, WP_MySQL_Lexer::MICROSECOND_SYMBOL ), + explode( '|', 'YEAR|MONTH|QUARTER|DAY|DAY|HOUR|MINUTE|SECOND|MICROSECOND' ) + ); + if ( isset( $units[ $token->id ] ) ) { + return $units[ $token->id ]; + } + $name = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $name && 0 === strcasecmp( $name, 'dayofyear' ) ) { + return 'DOY'; + } + return null; + } + 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, + ); + } + 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, + ); + } + 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 + && strtolower( $token->get_value() ) !== $token->get_value() + && ( null === $next_token || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $next_token->id ) + ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::LOGICAL_OR_OPERATOR === $token->id ) { + return 'OR'; + } + + if ( WP_MySQL_Lexer::LOGICAL_AND_OPERATOR === $token->id ) { + return 'AND'; + } + return $token->get_bytes(); + } + 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 ); + } + 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 + ); + } + 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; + } + private function get_mysql_dml_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( + null !== $token + && in_array( + $token->id, + array( + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::STATUS_SYMBOL, + WP_MySQL_Lexer::TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::VALUE_SYMBOL, + ), + true + ) + ) { + return $token->get_value(); + } + return null; + } + 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 ); + } + 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 ); + } + 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 + ); + } + 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(); + } + private function needs_mysql_compatible_rewrite( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + $token = $tokens[ $i ]; + if ( $this->is_mysql_compatible_rewrite_token_marker( $token ) || $this->has_mysql_compatible_rewrite_call_marker( $tokens, $i, $end ) ) { + return true; + } + + if ( + WP_MySQL_Lexer::IDENTIFIER === $token->id + && strtolower( $token->get_value() ) !== $token->get_value() + && ( ! isset( $tokens[ $i + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id ) + ) { + return true; + } + } + return false; + } + private function is_mysql_compatible_rewrite_token_marker( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, WP_MySQL_Lexer::LOGICAL_AND_OPERATOR, WP_MySQL_Lexer::AT_TEXT_SUFFIX, WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL ), + true + ); + } + private function has_mysql_compatible_rewrite_call_marker( array $tokens, int $position, int $end ): bool { + foreach ( self::MYSQL_COMPATIBLE_REWRITE_CALL_MARKERS as $marker ) { + if ( null !== $this->{$marker[0]}( $tokens, $position, $end, ...( $marker[1] ?? array() ) ) ) { + return true; + } + } + return false; + } + 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; + } + private function mysql_create_table_target_exists( string $schema_name, string $table_name, bool $is_temporary ): bool { + if ( $is_temporary ) { + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); + if ( null !== $temporary_schema ) { + $this->mark_mysql_temporary_table_created( $table_name ); + return true; + } + return false; + } + + $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(); + } + private function get_mysql_variable_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || ! $this->is_mysql_select_variable_reference_token( $tokens[1] ?? null ) + ) { + return null; + } + + $position = 1; + $columns = array(); + $row = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $variable = $this->parse_mysql_select_variable_reference( $tokens, $position ); + if ( null === $variable ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + $column = $variable['display']; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $column = $this->get_mysql_projection_alias_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $column ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $column = $implicit_alias; + ++$position; + } + } + + $columns[] = $column; + $row[ $column ] = $variable['value']; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + break; + } + + ++$position; + if ( $this->is_mysql_select_variable_reference_token( $tokens[ $position ] ?? null ) ) { + continue; + } + if ( isset( $tokens[ $position ] ) && ! $this->is_at_mysql_query_end( $tokens, $position ) && ! $this->is_mysql_variable_select_fallback_boundary_token( $tokens[ $position ] ) ) { + return null; + } + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + if ( isset( $tokens[ $position ] ) && $this->is_mysql_variable_select_fallback_boundary_token( $tokens[ $position ] ) ) { + return null; + } + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + return array( + 'columns' => $columns, + 'row' => $row, + ); + } + private function is_mysql_variable_select_fallback_boundary_token( WP_MySQL_Token $token ): bool { + return in_array( $token->id, array( WP_MySQL_Lexer::FROM_SYMBOL, WP_MySQL_Lexer::GROUP_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL, WP_MySQL_Lexer::WHERE_SYMBOL ), true ); + } + private function is_mysql_select_variable_reference_token( ?WP_MySQL_Token $token ): bool { + return null !== $token && in_array( $token->id, array( WP_MySQL_Lexer::AT_TEXT_SUFFIX, WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL ), true ); + } + private function parse_mysql_select_variable_reference( array $tokens, int &$position ): ?array { + $token = $tokens[ $position ] ?? null; + if ( null === $token ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $token->id ) { + $display = $token->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 !== $token->id ) { + return null; + } + + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $name || null === $display ) { + return null; + } + + $value = $this->get_mysql_system_variable_value( $name, $scope ); + if ( null === $value ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + return array( + 'display' => $display, + 'value' => $value, + ); + } + private function get_found_rows_query_column_name( 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::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 null; + } + + if ( $this->is_at_mysql_query_end( $tokens, 4 ) ) { + return 'FOUND_ROWS()'; + } + + if ( + isset( $tokens[4], $tokens[5] ) + && WP_MySQL_Lexer::AS_SYMBOL === $tokens[4]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[5] ) + && $this->is_at_mysql_query_end( $tokens, 6 ) + ) { + return $this->get_mysql_identifier_token_value( $tokens[5] ); + } + + if ( + isset( $tokens[4] ) + && null !== $this->get_mysql_identifier_token_value( $tokens[4] ) + && $this->is_at_mysql_query_end( $tokens, 5 ) + ) { + return $this->get_mysql_identifier_token_value( $tokens[4] ); + } + return null; + } + private function normalize_column_meta( PDOStatement $stmt, array $excluded_names = array() ): array { + static $native_type_map = array( + 'int2' => array( 'SHORT', PDO::PARAM_INT, 2, 63 ), + 'smallint' => array( 'SHORT', PDO::PARAM_INT, 2, 63 ), + 'int4' => array( 'LONG', PDO::PARAM_INT, 3, 63 ), + 'integer' => array( 'LONG', PDO::PARAM_INT, 3, 63 ), + 'int8' => array( 'LONGLONG', PDO::PARAM_INT, 8, 63 ), + 'bigint' => array( 'LONGLONG', PDO::PARAM_INT, 8, 63 ), + 'bytea' => array( 'BLOB', PDO::PARAM_LOB, 252, 63 ), + 'blob' => array( 'BLOB', PDO::PARAM_LOB, 252, 63 ), + 'bool' => array( 'TINY', PDO::PARAM_BOOL, 1, 63 ), + 'boolean' => array( 'TINY', PDO::PARAM_BOOL, 1, 63 ), + 'numeric' => array( 'NEWDECIMAL', PDO::PARAM_STR, 246, 63 ), + 'decimal' => array( 'NEWDECIMAL', PDO::PARAM_STR, 246, 63 ), + 'float4' => array( 'FLOAT', PDO::PARAM_STR, 4, 63 ), + 'float8' => array( 'DOUBLE', PDO::PARAM_STR, 5, 63 ), + 'date' => array( 'DATE', PDO::PARAM_STR, 10, 63 ), + 'time' => array( 'TIME', PDO::PARAM_STR, 11, 63 ), + 'timestamp' => array( 'DATETIME', PDO::PARAM_STR, 12, 63 ), + 'timestamptz' => array( 'DATETIME', PDO::PARAM_STR, 12, 63 ), + 'datetime' => array( 'DATETIME', PDO::PARAM_STR, 12, 63 ), + ); + $meta = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + $column_meta = $stmt->getColumnMeta( $i ); + $column_meta = is_array( $column_meta ) ? $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 = $native_type_map[ $native_type ] ?? array( 'VAR_STRING', PDO::PARAM_STR, 253, 255 ); + + $normalized_column_meta = array( + 'native_type' => $type[0], + 'pdo_type' => $type[1], + 'flags' => isset( $column_meta['flags'] ) && is_array( $column_meta['flags'] ) ? $column_meta['flags'] : array(), + 'table' => $table, + 'name' => $name, + 'len' => isset( $column_meta['len'] ) ? (int) $column_meta['len'] : 0, + 'precision' => isset( $column_meta['precision'] ) ? (int) $column_meta['precision'] : 0, + 'mysqli:orgname' => $name, + 'mysqli:orgtable' => $table, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => $type[3], + 'mysqli:flags' => 0, + 'mysqli:type' => $type[2], + ); + + if ( isset( $excluded_names[ $normalized_column_meta['name'] ] ) ) { + continue; + } + + $meta[] = $normalized_column_meta; + } + return $meta; + } +} diff --git a/packages/mysql-on-sqlite/src/postgresql/trait-wp-postgresql-driver-rewrite-rules.php b/packages/mysql-on-sqlite/src/postgresql/trait-wp-postgresql-driver-rewrite-rules.php new file mode 100644 index 000000000..67a98e883 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/trait-wp-postgresql-driver-rewrite-rules.php @@ -0,0 +1,3419 @@ +get_mysql_top_level_query_dispatch_rules() as $rule ) { + $result = $this->apply_mysql_top_level_query_dispatch_rule( $rule, $query, $translated_for_postgresql, $fetch_mode, $fetch_mode_args, $query_context ); + if ( null !== $result ) { + return $result; + } + } + return null; + } + private function get_mysql_top_level_query_dispatch_rules(): array { + return array( array( 'result', 'execute_mysql_runtime_setting_query' ), array( 'parse_result', 'get_mysql_use_database_name', 'execute_mysql_use_statement' ), array( 'parse_result', 'get_mysql_transaction_control_query', 'execute_mysql_transaction_control_query' ), array( 'parse_result', 'get_mysql_savepoint_query', 'execute_mysql_savepoint_query' ), array( 'fetch_result', 'execute_mysql_static_select_query' ), array( 'fetch_result', 'execute_mysql_show_query' ), array( 'reject', 'reject_unsupported_mysql_constructs', array( array( 'contains_unsupported_mysql_group_concat_function_query', 'Unsupported MySQL runtime function form.' ), array( 'contains_unsupported_mysql_extract_function_query', 'Unsupported MySQL runtime function form.' ), array( 'contains_unsupported_mysql_fulltext_search_query', 'Unsupported MySQL full-text search syntax.' ) ) ), array( 'translate_first', array( 'translate_direct_information_schema_cte_select_query', 'translate_direct_information_schema_select_query', 'translate_application_select_with_direct_information_schema_nested_selects' ) ), array( 'reject_untranslated', 'should_reject_information_schema_backend_query', 'Unsupported information_schema query.' ), array( 'parse_result', 'get_mysql_lock_tables_query', 'execute_mysql_lock_tables_query' ), array( 'parse_noop', 'get_mysql_flush_query' ), array( 'parse_result', 'get_mysql_truncate_table_query', 'execute_mysql_truncate_table_query' ), array( 'parse_result', 'get_found_rows_query_column_name', 'execute_mysql_found_rows_query' ), array( 'parse_result', 'translate_mysql_create_table_select_query', 'execute_mysql_translated_create_table_query' ), array( 'parse_result', 'translate_mysql_create_table_like_query', 'execute_mysql_translated_create_table_query' ), array( 'reject_if', 'contains_unsupported_mysql_create_table_column_attribute_query', 'Unsupported CREATE TABLE column attribute.' ), array( 'result', 'execute_mysql_create_table_query' ), array( 'parse_statements', 'translate_mysql_view_query', null, array( WP_MySQL_Lexer::CREATE_SYMBOL, 'CREATE VIEW' ) ), array( 'parse_result', 'translate_mysql_create_index_query', 'execute_mysql_create_index_query' ), array( 'message', 'get_unsupported_mysql_create_statement_message' ), array( 'parse_result', 'translate_mysql_dbdelta_alter_table_query', 'execute_mysql_dbdelta_alter_query' ), array( 'reject', 'reject_mysql_statement_prefix', array( WP_MySQL_Lexer::ALTER_SYMBOL, WP_MySQL_Lexer::TABLE_SYMBOL ), 'Unsupported ALTER TABLE statement.' ), array( 'parse_statements', 'translate_mysql_view_query', null, array( WP_MySQL_Lexer::ALTER_SYMBOL, 'ALTER VIEW' ) ), array( 'parse_admin', 'translate_mysql_drop_table_query', true ), array( 'parse_admin', 'translate_mysql_drop_view_query', false ), array( 'parse_admin', 'translate_mysql_drop_index_query', true ), array( 'message', 'get_unsupported_mysql_drop_statement_message' ), array( 'parse_admin', 'translate_mysql_rename_table_query', true ), array( 'reject', 'reject_mysql_statement_prefix', array( WP_MySQL_Lexer::RENAME_SYMBOL, WP_MySQL_Lexer::TABLE_SYMBOL ), 'Unsupported RENAME TABLE statement.' ), array( 'fetch_result', 'execute_mysql_metadata_show_query' ) ); + } + private function apply_mysql_top_level_query_dispatch_rule( array $rule, string &$query, bool &$translated_for_postgresql, $fetch_mode, array $fetch_mode_args, ?array &$query_context = null ) { + switch ( $rule[0] ) { + case 'result': + return $this->{$rule[1]}( $query ); + case 'fetch_result': + return $this->{$rule[1]}( $query, $fetch_mode, ...$fetch_mode_args ); + case 'reject': + $this->{$rule[1]}( $query, $rule[2], ...( isset( $rule[3] ) ? array( $rule[3] ) : array() ) ); + return null; + case 'translate_first': + $translated_query = $this->translate_first_mysql_query( $query, $rule[1], $query_context ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + return null; + case 'reject_if': + case 'reject_untranslated': + if ( ( 'reject_if' === $rule[0] || ! $translated_for_postgresql ) && $this->evaluate_mysql_query_context_guard( $rule[1], $query, $query_context ) ) { + throw new InvalidArgumentException( $rule[2] ); + } + return null; + case 'message': + $message = $this->{$rule[1]}( $query ); + if ( null !== $message ) { + throw new InvalidArgumentException( $message ); + } + return null; + } + $parsed_query = $this->{$rule[1]}( $query, ...( $rule[3] ?? array() ) ); + if ( null === $parsed_query ) { + return null; + } + if ( 'parse_noop' === $rule[0] ) { + return $this->execute_mysql_admin_noop_query(); + } + if ( 'parse_result' === $rule[0] ) { + return $this->{$rule[2]}( $parsed_query ); + } + if ( 'parse_statements' === $rule[0] ) { + return $this->execute_postgresql_statements( $parsed_query['statements'] ); + } + + $result = $this->execute_mysql_admin_statements( $parsed_query['statements'], $rule[2] ); + if ( 'translate_mysql_drop_table_query' === $rule[1] ) { + $this->update_mysql_table_schema_state_after_drop( $parsed_query ); + } + return $result; + } + private function reject_mysql_statement_prefix( string $query, array $token_ids, string $message ): void { + $tokens = $this->get_mysql_tokens( $query ); + foreach ( $token_ids as $position => $token_id ) { + if ( ( $tokens[ $position ]->id ?? null ) !== $token_id ) { + return; + } + } + throw new InvalidArgumentException( $message ); + } + private function evaluate_mysql_query_context_guard( string $guard_name, string $query, ?array &$query_context = null ): bool { + if ( 'should_reject_information_schema_backend_query' === $guard_name ) { + return $this->should_reject_information_schema_backend_query( $query, $query_context ); + } + return $this->$guard_name( $query ); + } + + private function get_mysql_post_translation_unsupported_construct_guards(): array { + return array( array( 'contains_mysql_index_hint_syntax', 'Unsupported MySQL index hint syntax.' ), array( 'contains_unsupported_mysql_date_arithmetic_function_query', 'Unsupported MySQL date arithmetic statement.' ), array( 'contains_unsupported_mysql_range_scanner_query', 'Unsupported MySQL runtime function form.', array( 'contains_unsupported_mysql_date_format_function' ) ), array( 'contains_unsupported_mysql_range_scanner_query', 'Unsupported MySQL runtime function form.', array( 'contains_unsupported_mysql_rand_function' ) ), array( 'contains_unsupported_mysql_range_scanner_query', 'Unsupported MySQL runtime function form.', array( 'contains_unsupported_mysql_convert_function' ) ), array( 'contains_unsupported_mysql_fulltext_search_query', 'Unsupported MySQL full-text search syntax.' ), array( 'contains_unsupported_mysql_range_scanner_query', 'Unsupported MySQL runtime function form.', array( 'contains_unsupported_mysql_common_function' ) ), array( 'contains_unsupported_mysql_group_concat_function_query', 'Unsupported MySQL runtime function form.' ), array( 'contains_unsupported_mysql_week_function_query', 'Unsupported MySQL WEEK() mode.' ) ); + } + + private function execute_mysql_savepoint_query( string $savepoint_query ): int { + $this->execute_postgresql_logged_statement( $savepoint_query ); + return $this->execute_mysql_admin_noop_query(); + } + + private function execute_mysql_found_rows_query( string $found_rows_column ) { + $this->last_result = array( (object) array( $found_rows_column => (string) $this->last_found_rows ) ); + $this->last_column_meta = array( + array_combine( array( 'name', 'table', 'mysqli:orgtable', 'mysqli:orgname', 'mysqli:db', 'mysqli:charsetnr', 'mysqli:flags', 'mysqli:type', 'len', 'precision', 'native_type' ), array( $found_rows_column, '', '', 'FOUND_ROWS()', $this->db_name, 63, 0, 8, 20, 0, 'integer' ) ), + ); + return $this->last_result; + } + + private function execute_mysql_create_table_query( string $query ): ?int { + if ( ! $this->is_create_table_query( $query ) ) { + return null; + } + + $create_table_target = $this->get_mysql_create_table_target( $query ); + if ( null === $create_table_target ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + if ( $this->mysql_create_table_if_not_exists_target_exists( $query ) ) { + return $this->execute_mysql_admin_noop_query(); + } + + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + return $this->execute_mysql_translated_create_table_query( + array_merge( + $create_table_target, + array( + 'metadata_query' => $query, + 'statements' => $this->qualify_translated_create_table_statements( $translator->translate_schema( $query ), $create_table_target['schema'], $create_table_target['table'], $create_table_target['temporary'] ), + ) + ) + ); + } + + private function execute_mysql_create_index_query( array $create_index_query ): int { + $metadata = $create_index_query['metadata']; + $this->assert_postgresql_catalog_recoverable_mysql_index_metadata( $metadata['index'] ); + $this->execute_postgresql_statements( $create_index_query['statements'] ); + $this->clear_mysql_metadata_caches(); + $this->sync_postgresql_catalog_index_comment( $metadata['schema'], $metadata['table'], $metadata['index'], true ); + return $this->execute_mysql_admin_noop_query(); + } + + private function execute_mysql_dbdelta_alter_query( array $alter_query ): int { + $result = $this->execute_postgresql_statements( $alter_query['statements'] ); + $this->apply_mysql_dbdelta_alter_metadata( $alter_query['metadata'] ); + if ( $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'drop_index' ) || $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'rename_table' ) || $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'set_auto_increment' ) ) { + $this->last_result = 0; + return $this->last_result; + } + return $result; + } + + private function apply_mysql_dml_rewrite_rules( string &$query, bool &$translated_for_postgresql, ?array &$dml_identity_repair_query, ?int &$replace_return_value, bool $mysql_update_ignore_query, ?array &$query_context = null ) { + $first_token = $this->get_mysql_query_context_first_token_id( $query, $query_context ); + foreach ( $this->get_mysql_dml_rewrite_rules() as $rule ) { + $contains = $rule[1] ?? null; + if ( ! in_array( $first_token, (array) $rule[0], true ) || ( null !== $contains && false === stripos( $query, $contains ) ) ) { + continue; + } + + if ( null === $rule[2] ) { + $guard = $rule[4] ?? null; + if ( ( true === $guard || true === ( $rule[5] ?? false ) ) && $translated_for_postgresql ) { + continue; + } + if ( true !== $guard && null !== $guard && ! $this->evaluate_mysql_dml_context_guard( $guard, $query, $query_context ) ) { + continue; + } + throw new InvalidArgumentException( $rule[3] ); + } + + $translated_query = $this->{$rule[2]}( $query ); + if ( null === $translated_query ) { + continue; + } + + $result = $this->apply_mysql_dml_rewrite_result( $rule[3], $translated_query, $query, $translated_for_postgresql, $dml_identity_repair_query, $replace_return_value, $mysql_update_ignore_query ); + if ( null !== $result ) { + return $result; + } + $first_token = $this->get_mysql_query_context_first_token_id( $query, $query_context ); + } + return null; + } + + private function get_mysql_dml_rewrite_rules(): array { + return array( array( WP_MySQL_Lexer::DELETE_SYMBOL, 'REGEXP', 'translate_wordpress_options_regexp_delete_query', 'sql' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, 'SUBSTRING', 'translate_wordpress_expired_transients_delete_query', 'execute_statement' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, 'LEFT', 'translate_mysql_left_join_orphan_delete_query', 'sql' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, null, 'translate_mysql_multi_target_delete_query', 'execute_multi_target_delete' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, 'JOIN', 'translate_mysql_single_target_join_delete_query', 'sql' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, null, 'translate_simple_mysql_delete_query', 'sql' ), array( WP_MySQL_Lexer::DELETE_SYMBOL, null, null, 'Unsupported DELETE statement.', true ), array( WP_MySQL_Lexer::INSERT_SYMBOL, 'DUPLICATE', 'translate_mysql_on_duplicate_key_update_query', 'upsert' ), array( WP_MySQL_Lexer::INSERT_SYMBOL, 'DUPLICATE', null, 'Unsupported ON DUPLICATE KEY UPDATE statement.', 'is_unsupported_mysql_on_duplicate_key_update_query' ), array( WP_MySQL_Lexer::REPLACE_SYMBOL, null, 'translate_simple_mysql_replace_query', 'replace' ), array( WP_MySQL_Lexer::REPLACE_SYMBOL, null, null, 'Unsupported REPLACE statement.', 'is_mysql_replace_query' ), array( WP_MySQL_Lexer::INSERT_SYMBOL, null, 'translate_simple_mysql_insert_query', 'dml' ), array( WP_MySQL_Lexer::INSERT_SYMBOL, null, null, 'Unsupported INSERT statement.', 'is_unsupported_mysql_insert_set_query', true ), array( WP_MySQL_Lexer::INSERT_SYMBOL, null, 'translate_simple_mysql_insert_select_query', 'dml' ), array( WP_MySQL_Lexer::INSERT_SYMBOL, null, null, 'Unsupported INSERT statement.', 'is_unsupported_mysql_insert_query', true ), array( WP_MySQL_Lexer::WITH_SYMBOL, 'UPDATE', 'translate_mysql_cte_prefixed_update_query', 'sql' ), array( WP_MySQL_Lexer::UPDATE_SYMBOL, null, 'translate_mysql_multi_target_update_query', 'multi_target_update' ), array( WP_MySQL_Lexer::UPDATE_SYMBOL, null, 'translate_simple_mysql_update_query', 'update' ), array( array( WP_MySQL_Lexer::UPDATE_SYMBOL, WP_MySQL_Lexer::WITH_SYMBOL ), null, null, 'Unsupported UPDATE statement.', 'is_unsupported_mysql_update_rewrite_query', true ) ); + } + + private function apply_mysql_dml_rewrite_result( string $result_type, $translated_query, string &$query, bool &$translated_for_postgresql, ?array &$dml_identity_repair_query, ?int &$replace_return_value, bool $mysql_update_ignore_query ) { + switch ( $result_type ) { + case 'execute_statement': + return $this->execute_postgresql_statements( array( $translated_query ) ); + case 'execute_multi_target_delete': + return $this->execute_mysql_multi_target_delete_query( $translated_query ); + case 'upsert': + case 'replace': + if ( 'replace' === $result_type && null !== $translated_query['conflict_column'] && empty( $translated_query['replace_select_materialized'] ) ) { + $replace_return_value = $this->get_mysql_replace_return_value( $translated_query ); + } + if ( 'upsert' === $result_type && ! empty( $translated_query['upsert_select_materialized'] ) ) { + return $this->execute_materialized_mysql_upsert_select_statements( $translated_query ); + } + if ( isset( $translated_query['statements'] ) && is_array( $translated_query['statements'] ) ) { + if ( 'upsert' === $result_type ) { + return $this->execute_translated_dml_statements( $translated_query ); + } + return ! empty( $translated_query['replace_select_materialized'] ) + ? $this->execute_materialized_mysql_replace_select_statements( $translated_query ) + : $this->execute_translated_dml_statements( $translated_query, $replace_return_value ); + } + // Fall through. + case 'dml': + $dml_identity_repair_query = $translated_query; + break; + case 'multi_target_update': + return $mysql_update_ignore_query + ? $this->execute_mysql_update_ignore_query( $translated_query, true ) + : $this->execute_mysql_multi_target_update_query( $translated_query ); + case 'update': + if ( $mysql_update_ignore_query ) { + return $this->execute_mysql_update_ignore_query( $translated_query ); + } + break; + } + + $query = is_array( $translated_query ) ? $translated_query['sql'] : $translated_query; + $translated_for_postgresql = true; + return null; + } + private function evaluate_mysql_dml_context_guard( string $guard_name, string $query, ?array &$query_context = null ): bool { + switch ( $guard_name ) { + case 'is_mysql_replace_query': + return $this->is_mysql_replace_query( $query, $query_context ); + case 'is_unsupported_mysql_insert_query': + return $this->is_unsupported_mysql_insert_query( $query, $query_context ); + case 'is_unsupported_mysql_insert_set_query': + return $this->is_unsupported_mysql_insert_set_query( $query, $query_context ); + case 'is_unsupported_mysql_update_rewrite_query': + return $this->is_unsupported_mysql_update_rewrite_query( $query, $query_context ); + } + return $this->$guard_name( $query ); + } + + private function is_unsupported_mysql_update_rewrite_query( string $query, ?array &$query_context = null ): bool { + $update_tokens = $this->get_mysql_query_context_tokens( $query, $query_context ); + if ( ! isset( $update_tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $update_tokens[0]->id ) { + return isset( $update_tokens[0] ) && WP_MySQL_Lexer::UPDATE_SYMBOL === $update_tokens[0]->id; + } + $update_end = $this->get_mysql_query_context_statement_end_position( $query_context, 1 ); + return null !== $update_end && null !== $this->find_top_level_mysql_query_context_token( $query_context, WP_MySQL_Lexer::UPDATE_SYMBOL, 1, $update_end ); + } + + private function translate_wordpress_options_regexp_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + foreach ( array( + 1 => WP_MySQL_Lexer::FROM_SYMBOL, + 3 => WP_MySQL_Lexer::WHERE_SYMBOL, + 5 => WP_MySQL_Lexer::REGEXP_SYMBOL, + 6 => array( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT ), + ) as $position => $token_ids ) { + if ( ! isset( $tokens[ $position ] ) || ! in_array( $tokens[ $position ]->id, (array) $token_ids, true ) ) { + return null; + } + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[2] ?? null ); + $column = $this->get_mysql_identifier_token_value( $tokens[4] ?? null ); + if ( null === $table_name || null === $column || ! $this->is_wordpress_options_table_name( $table_name ) || ! $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() ) + ); + } + 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]; + + $transient_timeout_descriptors = array( array( '_transient_timeout_', 12 ), array( '_site_transient_timeout_', 17 ) ); + if ( ! $this->is_wordpress_options_table_name( $table_name ) || ! in_array( array( $timeout_prefix, $substring_from ), $transient_timeout_descriptors, true ) ) { + return null; + } + $timeout_value_sql = $this->get_postgresql_mysql_numeric_cast_sql( 'b.option_value' ); + 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 %7$s < %8$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 ), + $timeout_value_sql, + $expires_before + ); + } + private function translate_mysql_left_join_orphan_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + foreach ( array( array( 2, WP_MySQL_Lexer::FROM_SYMBOL ), array( 5, WP_MySQL_Lexer::LEFT_SYMBOL ), array( 6, WP_MySQL_Lexer::JOIN_SYMBOL ), array( 9, WP_MySQL_Lexer::ON_SYMBOL ) ) as list( $position, $token_id ) ) { + if ( ! isset( $tokens[ $position ] ) || $token_id !== $tokens[ $position ]->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; + } + + $identifiers = array( + 'delete_alias' => $this->get_mysql_identifier_token_value( $tokens[1] ?? null ), + 'target_table' => $this->get_mysql_identifier_token_value( $tokens[3] ?? null ), + 'target_alias' => $this->get_mysql_identifier_token_value( $tokens[4] ?? null ), + 'joined_table' => $this->get_mysql_identifier_token_value( $tokens[7] ?? null ), + 'joined_alias' => $this->get_mysql_identifier_token_value( $tokens[8] ?? null ), + ); + if ( + in_array( null, $identifiers, true ) + || strtolower( $identifiers['delete_alias'] ) !== strtolower( $identifiers['target_alias'] ) + ) { + return null; + } + + if ( ! $this->is_mysql_null_rejected_join_alias_predicate( $tokens, $where_position + 1, $statement_end, $identifiers['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( $identifiers['target_table'] ), + $this->translate_mysql_identifier_value_to_postgresql( $identifiers['target_alias'] ), + $this->connection->quote_identifier( $identifiers['joined_table'] ), + $this->translate_mysql_identifier_value_to_postgresql( $identifiers['joined_alias'] ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 10, $where_position ) + ); + } + private function translate_mysql_multi_target_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[1] ) ) { + return null; + } + + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id ) { + $using_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::USING_SYMBOL, $position + 1, $statement_end ); + if ( null === $using_position || $position + 1 >= $using_position || $using_position + 1 >= $statement_end ) { + return null; + } + + $target_aliases = $this->parse_mysql_delete_target_aliases( $tokens, $position + 1, $using_position ); + $table_references_start = $using_position + 1; + } else { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $position, $statement_end ); + if ( null === $from_position || $position >= $from_position || $from_position + 1 >= $statement_end ) { + return null; + } + + $target_aliases = $this->parse_mysql_delete_target_aliases( $tokens, $position, $from_position ); + $table_references_start = $from_position + 1; + } + + if ( null === $target_aliases ) { + return null; + } + + $clauses = $this->get_mysql_joined_dml_clause_positions( $tokens, $table_references_start, $statement_end ); + if ( null === $clauses ) { + return null; + } + + $from_end = $clauses['body_end']; + if ( $table_references_start >= $from_end ) { + return null; + } + + $source_translation = $this->get_mysql_joined_dml_source_translation( $query, $tokens, $table_references_start, $from_end, true ); + if ( null === $source_translation ) { + return null; + } + $scope = $source_translation['scope']; + $source_sql = $source_translation['sql']; + + $target_tables = array(); + $target_groups = array(); + foreach ( $target_aliases as $target_alias ) { + $target_key = strtolower( $target_alias ); + if ( ! isset( $scope['aliases'][ $target_key ] ) ) { + return null; + } + + $target_table = $scope['aliases'][ $target_key ]; + $this->get_mysql_schema_aware_table_backend_schema( + array( + 'schema' => $target_table['schema'], + 'table' => $target_table['table'], + ), + 'DELETE' + ); + $target_physical_name = strtolower( $target_table['schema'] . '.' . $target_table['table'] ); + if ( ! isset( $target_groups[ $target_physical_name ] ) ) { + $target_groups[ $target_physical_name ] = array( + 'alias' => $target_alias, + 'schema' => $target_table['schema'], + 'table' => $target_table['table'], + 'ctid_aliases' => array(), + ); + } + + $target_tables[] = array( + 'alias' => $target_alias, + 'table' => $target_table['table'], + 'physical_name' => $target_physical_name, + ); + } + + $where_sql = ''; + if ( null !== $clauses['where'] ) { + $where_end = $clauses['order'] ?? $clauses['limit'] ?? $statement_end; + if ( $clauses['where'] + 1 >= $where_end ) { + return null; + } + + $where = $this->translate_mysql_joined_dml_where_sql( + $query, + $tokens, + $clauses['where'] + 1, + $where_end, + $scope, + $source_translation['context'] ?? null, + true + ); + if ( null === $where ) { + return null; + } + $where_sql = ' WHERE ' . $where; + } + + $tail = $this->translate_simple_mysql_dml_order_limit_sql( $tokens, $clauses['order'], $clauses['limit'], $statement_end, $scope ); + if ( null === $tail ) { + return null; + } + $order_sql = $tail['order']; + $limit_sql = $tail['limit']; + + $select_columns = array(); + foreach ( $target_tables as $index => $target_table ) { + $ctid_alias = 'mysql_delete_target_' . $index . '_ctid'; + $target_alias_sql = $this->connection->quote_identifier( $target_table['alias'] ); + + $select_columns[] = sprintf( + '%s.ctid AS %s', + $target_alias_sql, + $this->connection->quote_identifier( $ctid_alias ) + ); + $target_groups[ $target_table['physical_name'] ]['ctid_aliases'][] = $ctid_alias; + } + + $delete_ctes = array(); + $count_parts = array(); + foreach ( array_values( $target_groups ) as $index => $target_group ) { + $delete_cte_name = 'mysql_delete_target_' . $index; + $target_alias_sql = $this->connection->quote_identifier( $target_group['alias'] ); + if ( 1 === count( $target_group['ctid_aliases'] ) ) { + $ctid_predicate = sprintf( + '%s.ctid = mysql_delete_rows.%s', + $target_alias_sql, + $this->connection->quote_identifier( $target_group['ctid_aliases'][0] ) + ); + } else { + $ctid_selects = array(); + foreach ( $target_group['ctid_aliases'] as $ctid_alias ) { + $ctid_selects[] = sprintf( + 'SELECT %s FROM mysql_delete_rows', + $this->connection->quote_identifier( $ctid_alias ) + ); + } + $ctid_predicate = sprintf( + '%s.ctid IN (%s)', + $target_alias_sql, + implode( ' UNION ', $ctid_selects ) + ); + } + + $delete_ctes[] = sprintf( + '%s AS (DELETE FROM %s AS %s USING mysql_delete_rows WHERE %s RETURNING 1)', + $delete_cte_name, + $this->get_postgresql_table_identifier_sql( $target_group['schema'], $target_group['table'] ), + $target_alias_sql, + $ctid_predicate + ); + $count_parts[] = sprintf( '(SELECT COUNT(*) FROM %s)', $delete_cte_name ); + } + return sprintf( + 'WITH mysql_delete_rows AS MATERIALIZED (SELECT %s FROM %s%s%s%s), %s SELECT %s AS affected_rows', + implode( ', ', $select_columns ), + $source_sql, + $where_sql, + $order_sql, + $limit_sql, + implode( ', ', $delete_ctes ), + implode( ' + ', $count_parts ) + ); + } + private function get_direct_information_schema_dml_source_translation( string $query, array $tokens, int $source_start, int $source_end ): ?array { + $parsed_sources = $this->parse_direct_information_schema_select_sources( $query, $tokens, $source_start, $source_end ); + if ( null === $parsed_sources ) { + return null; + } + + $context = array( + 'sources' => $parsed_sources['sources'], + 'join_predicate_ranges' => $parsed_sources['join_predicate_ranges'], + 'join_predicate_replacements' => $parsed_sources['join_predicate_replacements'], + 'using_columns' => $parsed_sources['using_columns'], + ); + + $scope = array( + 'tables' => array(), + 'aliases' => array(), + 'unknown' => false, + ); + foreach ( $context['sources'] as $source ) { + if ( ! isset( $source['table'] ) ) { + continue; + } + + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( 'public', $source['table'] ), + 'table' => $source['table'], + ); + $alias = strtolower( $source['alias'] ); + if ( isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $scope['tables'][] = $table; + $scope['aliases'][ $alias ] = $table; + } + + if ( empty( $scope['tables'] ) ) { + return null; + } + + $sql = $this->translate_direct_information_schema_range_to_postgresql( + null, + $tokens, + $source_start, + $source_end, + $context, + array( + 'replacements' => $context['join_predicate_replacements'], + 'expression_ranges' => $context['join_predicate_ranges'], + 'include_source_replacements' => true, + ) + ); + if ( null === $sql ) { + return null; + } + return array( + 'scope' => $scope, + 'sql' => $sql, + 'context' => $context, + ); + } + private function get_mysql_joined_dml_source_translation( string $query, array $tokens, int $source_start, int $source_end, bool $translate_non_public_schema ): ?array { + if ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + || $this->direct_information_schema_source_range_references_information_schema( $tokens, $source_start, $source_end ) + ) { + return $this->get_direct_information_schema_dml_source_translation( $query, $tokens, $source_start, $source_end ); + } + + $scope = $this->get_mysql_select_scope( $tokens, $source_start, $source_end ); + if ( null === $scope || ! empty( $scope['unknown'] ) ) { + return null; + } + $source_sql = $translate_non_public_schema && $this->mysql_scope_references_non_public_schema( $scope ) + ? $this->translate_mysql_table_reference_range_to_postgresql( $tokens, $source_start, $source_end ) + : $this->translate_mysql_token_sequence_to_postgresql( $tokens, $source_start, $source_end ); + return null === $source_sql ? null : array( + 'scope' => $scope, + 'sql' => $source_sql, + ); + } + private function translate_direct_information_schema_dml_predicate_to_postgresql( ?string $query, array $tokens, int $start, int $end, array $context ): ?string { + return $this->translate_direct_information_schema_range_to_postgresql( + $query, + $tokens, + $start, + $end, + $context, + array( + 'nested_select_ranges' => array( + array( + 'start' => $start, + 'end' => $end, + ), + ), + 'nested_selects_if_present' => true, + 'reject_nested_select_unions' => true, + 'cover_nested_selects' => true, + ) + ); + } + private function translate_mysql_joined_dml_where_sql( string $query, array $tokens, int $start, int $end, array $scope, ?array $information_schema_context, bool $require_supported_expression ): ?string { + if ( null !== $information_schema_context ) { + return $this->translate_direct_information_schema_dml_predicate_to_postgresql( $query, $tokens, $start, $end, $information_schema_context ); + } + if ( $require_supported_expression && ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $start, $end ) ) { + return null; + } + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( $tokens, $start, $end, $scope ); + return $where['sql']; + } + private function parse_mysql_delete_target_aliases( array $tokens, int $start, int $end ): ?array { + $aliases = array(); + $position = $start; + + while ( $position < $end ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $alias ) { + return null; + } + ++$position; + + if ( + $position + 1 < $end + && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + && WP_MySQL_Lexer::MULT_OPERATOR === ( $tokens[ $position + 1 ]->id ?? null ) + ) { + $position += 2; + } + + $alias_key = strtolower( $alias ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + $aliases[ $alias_key ] = $alias; + + if ( $position === $end ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + ++$position; + } + return empty( $aliases ) ? null : array_values( $aliases ); + } + private function translate_mysql_single_target_join_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( WP_MySQL_Lexer::FROM_SYMBOL !== ( $tokens[2]->id ?? null ) ) { + 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; + } + + $clauses = $this->get_mysql_joined_dml_clause_positions( $tokens, 3, $statement_end ); + if ( null === $clauses || null === $clauses['where'] || 3 >= $clauses['where'] || $clauses['where'] + 1 >= $statement_end ) { + return null; + } + $where_position = $clauses['where']; + + $delete_alias = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); + $target_ref = $this->parse_mysql_table_reference( $tokens, 3, $where_position ); + if ( null === $delete_alias || null === $target_ref ) { + return null; + } + $this->get_mysql_writable_table_backend_schema( + array( + 'schema' => $target_ref['schema'], + 'table' => $target_ref['table'], + ), + 'DELETE' + ); + + $target_alias = $target_ref['alias'] ?? $target_ref['table']; + $accepted_alias_keys = array_map( 'strtolower', array( $target_alias, $target_ref['table'] ) ); + if ( ! in_array( strtolower( $delete_alias ), $accepted_alias_keys, true ) ) { + return null; + } + + if ( null === $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::JOIN_SYMBOL, $target_ref['position'], $where_position ) ) { + return null; + } + + $source_translation = $this->get_mysql_joined_dml_source_translation( $query, $tokens, 3, $where_position, false ); + if ( null === $source_translation ) { + return null; + } + $scope = $source_translation['scope']; + $source_sql = $source_translation['sql']; + + $where_end = $clauses['order'] ?? $clauses['limit'] ?? $statement_end; + if ( $where_position + 1 >= $where_end ) { + return null; + } + + $where_sql = $this->translate_mysql_joined_dml_where_sql( + $query, + $tokens, + $where_position + 1, + $where_end, + $scope, + $source_translation['context'] ?? null, + false + ); + if ( null === $where_sql ) { + return null; + } + + $tail = $this->translate_simple_mysql_dml_order_limit_sql( $tokens, $clauses['order'], $clauses['limit'], $statement_end, $scope ); + if ( null === $tail ) { + return null; + } + $order_sql = $tail['order']; + $limit_sql = $tail['limit']; + + $target_alias_sql = $this->connection->quote_identifier( $target_alias ); + return sprintf( + 'DELETE FROM %s AS %s WHERE %s.ctid IN (SELECT %s.ctid FROM %s WHERE %s%s%s)', + $this->connection->quote_identifier( $target_ref['table'] ), + $target_alias_sql, + $target_alias_sql, + $target_alias_sql, + $source_sql, + $where_sql, + $order_sql, + $limit_sql + ); + } + 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 ); + } + private function translate_simple_mysql_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $statement_end ); + if ( null === $table_reference ) { + return null; + } + + $table_name = $table_reference['table']; + $alias = $table_reference['alias']; + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $position, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position, $statement_end ); + + if ( + ( null !== $where_position && $where_position !== $position ) + || ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null === $where_position && null === $order_position && null !== $limit_position && $limit_position !== $position ) + ) { + return null; + } + + $unsupported_tokens = array( WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::JOIN_SYMBOL, WP_MySQL_Lexer::REGEXP_SYMBOL, WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, WP_MySQL_Lexer::USING_SYMBOL ); + $unsupported_token_scan_end = $order_position ?? $limit_position ?? $statement_end; + if ( $this->contains_top_level_mysql_token( $tokens, $position, $unsupported_token_scan_end, $unsupported_tokens ) ) { + return null; + } + + $where_end = $order_position ?? $limit_position ?? $statement_end; + $scope = $this->get_mysql_single_table_scope( $table_name, $alias ); + $where = $this->translate_simple_mysql_dml_where_sql( $query, $tokens, $where_position, $where_end, $scope ); + $tail = $this->translate_simple_mysql_dml_order_limit_sql( $tokens, $order_position, $limit_position, $statement_end, $scope ); + if ( null === $where || null === $tail ) { + return null; + } + $where_sql = $where['sql']; + $order_sql = $tail['order']; + $limit_sql = $tail['limit']; + + $table_sql = $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ); + if ( '' !== $order_sql || '' !== $limit_sql ) { + $subquery_where_sql = null === $where_sql ? '' : ' WHERE ' . $where_sql; + return sprintf( + 'DELETE FROM %s WHERE %s IN (SELECT %s FROM %s%s%s%s)', + $table_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $table_sql, + $subquery_where_sql, + $order_sql, + $limit_sql + ); + } + + $sql = 'DELETE FROM ' . $table_sql; + if ( null !== $where_sql ) { + $sql .= ' WHERE ' . $where_sql; + } + return $sql; + } + private function translate_simple_mysql_dml_where_sql( string $query, array $tokens, ?int $where_position, int $where_end, array $scope, array $cte_names = array() ): ?array { + if ( null === $where_position ) { + return array( 'sql' => null ); + } + + $where_replacements = $this->get_simple_mysql_dml_predicate_nested_select_replacements( $query, $tokens, $where_position + 1, $where_end, $cte_names ); + if ( + $where_position + 1 >= $where_end + || null === $where_replacements + || ! $this->is_supported_simple_mysql_expression_fragment_with_replacements( $tokens, $where_position + 1, $where_end, $where_replacements ) + ) { + return null; + } + + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope, + $where_replacements + ); + return array( 'sql' => $where['sql'] ); + } + private function translate_simple_mysql_dml_order_limit_sql( array $tokens, ?int $order_position, ?int $limit_position, int $statement_end, array $scope ): ?array { + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( $tokens, $order_position, $limit_position ?? $statement_end, $scope ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + return array( + 'order' => $order_sql, + 'limit' => $limit_sql, + ); + } + private function translate_mysql_on_duplicate_key_update_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $position = 1; + $ignore = false; + $header = $this->parse_mysql_insert_table_header( $tokens, $position, $ignore ); + if ( null === $header ) { + return null; + } + $table_name = $header['table']; + $table_reference_start = $header['start']; + $table_reference_end = $header['end']; + + $column_metadata = null; + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position ); + if ( null === $on_duplicate ) { + return null; + } + + $source = $this->parse_mysql_insert_like_dml_source( + $table_name, + $tokens, + $position, + $on_duplicate, + $column_metadata, + array( + 'allow_values_alias' => true, + ) + ); + if ( null === $source ) { + return null; + } + $columns = $source['columns']; + $value_rows = $source['value_rows']; + $value_range_rows = $source['value_range_rows']; + $probe_safe_rows = $source['probe_safe_rows']; + $upsert_source_aliases = $source['source_aliases']; + $insert_select_column_list = $source['insert_column_list']; + + if ( $this->is_mysql_dml_select_source_token( $tokens[ $position ] ?? null ) ) { + return $this->translate_mysql_insert_select_on_duplicate_key_update_query( + $query, + $table_name, + $columns, + $tokens, + $position, + $on_duplicate, + $statement_end, + $table_reference_start, + $table_reference_end, + $insert_select_column_list + ); + } + + $column_metadata = $this->normalize_mysql_dml_value_rows_for_columns( + $table_name, + $columns, + $value_rows, + $value_range_rows, + $tokens, + $column_metadata + ); + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup_from_rows( $column_metadata ); + $upsert_plan = $this->get_mysql_upsert_write_plan( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + array( + 'allow_omitted_conflict_target' => true, + 'allow_unresolved_conflict_target' => true, + 'table_column_lookup' => $table_column_lookup, + ) + ); + if ( null === $upsert_plan ) { + return null; + } + $conflict_target = $upsert_plan['conflict_target']; + $conflict_columns = $upsert_plan['conflict_columns']; + $conflict_indexes = $upsert_plan['conflict_indexes']; + $uses_omitted_conflict_target = $upsert_plan['uses_omitted_conflict_target']; + + $column_lookup = $this->get_mysql_dml_column_lookup( $columns ); + + $position = $on_duplicate + 4; + + $assignment_effects = array(); + $assignments = $this->parse_upsert_update_assignments( $table_name, $tokens, $position, $statement_end, $column_lookup, $table_column_lookup, $upsert_source_aliases, $assignment_effects ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + if ( null === $conflict_target ) { + if ( count( $value_rows ) < 2 ) { + return null; + } + + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + return null; + } + + $per_row_upsert = $this->get_mysql_per_row_upsert_statements_for_ambiguous_targets( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $assignments, + $assignment_effects['assigned_columns'] ?? array() + ); + if ( null === $per_row_upsert ) { + return null; + } + return $this->get_mysql_upsert_values_query( + $table_name, + $columns, + $per_row_upsert['inserted_value_rows'], + $value_rows, + null, + $per_row_upsert['conflict_columns'], + array( + 'statements' => $per_row_upsert['statements'], + 'conflict_index_groups' => $per_row_upsert['conflict_index_groups'], + ) + ); + } + + if ( $uses_omitted_conflict_target ) { + $inserted_value_rows = $value_rows; + } else { + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $conflict_target['parts'] + ); + if ( null === $inserted_value_rows ) { + return null; + } + } + + $last_insert_id_fields = $this->get_mysql_upsert_last_insert_id_query_fields( + $table_name, + $assignment_effects, + null, + $value_rows, + $probe_safe_rows, + $conflict_indexes, + count( $inserted_value_rows ) > 0 + ); + if ( false === $last_insert_id_fields ) { + return null; + } + + $conflict_sql = $this->get_postgresql_dml_conflict_update_sql( $conflict_target, $assignments ); + $upsert_query = $this->get_mysql_upsert_values_query( + $table_name, + $columns, + $inserted_value_rows, + $value_rows, + $this->get_postgresql_dml_insert_values_sql( + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + $columns, + $value_rows, + $conflict_sql + ), + $conflict_columns, + array( + 'conflict_indexes' => $conflict_indexes, + ) + ); + $upsert_query = array_merge( $upsert_query, $last_insert_id_fields ); + + if ( null !== $conflict_indexes && $this->has_duplicate_mysql_replace_conflict_value_rows( $value_rows, $probe_safe_rows, $conflict_indexes ) ) { + $upsert_query['statements'] = $this->get_postgresql_dml_insert_value_row_statements( $table_name, $columns, $value_rows, $conflict_sql ); + } + return $upsert_query; + } + private function get_mysql_upsert_values_query( string $table_name, array $columns, array $inserted_value_rows, array $insert_id_value_rows, ?string $sql, array $conflict_columns, array $extra = array() ): array { + $query = array( + 'action' => 'upsert', + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $inserted_value_rows[0] ?? array(), + 'value_rows' => $inserted_value_rows, + 'insert_id_value_rows' => $insert_id_value_rows, + 'conflict_columns' => $conflict_columns, + 'inserted_new_row' => count( $inserted_value_rows ) > 0, + ); + if ( null !== $sql ) { + $query['sql'] = $sql; + } + return array_merge( $query, $extra ); + } + private function is_unsupported_mysql_on_duplicate_key_update_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) + && WP_MySQL_Lexer::INSERT_SYMBOL === $tokens[0]->id + && null !== $this->find_on_duplicate_key_update_clause( $tokens, 1 ); + } + private function translate_mysql_insert_select_on_duplicate_key_update_query( + string $query, + string $table_name, + array $columns, + array $tokens, + int $position, + int $on_duplicate, + int $statement_end, + int $table_reference_start, + int $table_reference_end, + bool $insert_column_list = false + ): ?array { + $rewrite = $this->get_mysql_insert_select_rewrite_data( + $query, + $table_name, + $columns, + $tokens, + $position, + $on_duplicate, + $table_reference_start, + $table_reference_end, + $insert_column_list, + array(), + '', + false + ); + if ( null === $rewrite ) { + return null; + } + $select_start = $rewrite['select_start']; + $select_end = $rewrite['select_end']; + $insert_sql = $rewrite['sql']; + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ); + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + $literal_value_rows = null === $literal_value_row ? null : array( $literal_value_row['values'] ); + $literal_probe_safe_rows = null === $literal_value_row ? null : array( $literal_value_row['probe_safe_values'] ); + $explicit_identity_columns = array(); + $upsert_plan = $this->get_mysql_upsert_write_plan( + $table_name, + $columns, + $literal_value_rows, + $literal_probe_safe_rows, + array( + 'allow_ambiguous_conflict_candidates' => null === $literal_value_row, + ) + ); + if ( null === $upsert_plan ) { + return null; + } + $conflict_target = $upsert_plan['conflict_target']; + $conflict_columns = $upsert_plan['conflict_columns']; + $conflict_indexes = $upsert_plan['conflict_indexes']; + $ambiguous_conflict_candidates = $upsert_plan['ambiguous_conflict_candidates']; + + $column_lookup = $this->get_mysql_dml_column_lookup( $columns ); + + $assignment_position = $on_duplicate + 4; + $assignment_effects = array(); + $assignments = $this->parse_upsert_update_assignments( + $table_name, + $tokens, + $assignment_position, + $statement_end, + $column_lookup, + $table_column_lookup, + array(), + $assignment_effects + ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $assignment_position ) ) { + return null; + } + + if ( null === $conflict_target ) { + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + return null; + } + + if ( + null !== $auto_increment_column + && ! $this->can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( + $auto_increment_column, + $columns, + $conflict_columns + ) + ) { + if ( ! $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) ) { + return null; + } + + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } + + $materialized_flow = $this->get_mysql_insert_select_upsert_materialized_flow_for_ambiguous_targets( + $table_name, + $columns, + $tokens, + $select_start, + $select_end, + $ambiguous_conflict_candidates, + $assignments, + $assignment_effects['assigned_columns'] ?? array() + ); + if ( null === $materialized_flow ) { + return null; + } + return $this->get_mysql_upsert_select_query( + $table_name, + $columns, + $insert_sql, + $conflict_columns, + null, + null, + null, + $explicit_identity_columns, + $materialized_flow + ); + } + + $last_insert_id_fields = $this->get_mysql_upsert_last_insert_id_query_fields( + $table_name, + $assignment_effects, + $literal_value_row, + null, + null, + $conflict_indexes, + false + ); + if ( false === $last_insert_id_fields ) { + return null; + } + $conflict_sql = $this->get_postgresql_dml_conflict_update_sql( $conflict_target, $assignments ); + + $inserted_value_rows = null; + $insert_id_value_rows = null; + if ( null !== $auto_increment_column ) { + if ( null === $literal_value_row ) { + if ( + ! $this->can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( + $auto_increment_column, + $columns, + $conflict_columns + ) + ) { + if ( ! $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) ) { + return null; + } + + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } + } else { + $insert_id_value_rows = array( $literal_value_row['insert_id_values'] ); + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $insert_id_value_rows, + array( $literal_value_row['probe_safe_values'] ), + $conflict_target['parts'] + ); + if ( null === $inserted_value_rows ) { + return null; + } + } + } + + $upsert_query = $this->get_mysql_upsert_select_query( + $table_name, + $columns, + sprintf( + '%s %s', + $insert_sql, + $conflict_sql + ), + $conflict_columns, + $conflict_indexes, + $inserted_value_rows, + $insert_id_value_rows, + $explicit_identity_columns + ); + $upsert_query = array_merge( $upsert_query, $last_insert_id_fields ); + + if ( null === $literal_value_row ) { + $materialized_flow = $this->get_mysql_insert_select_upsert_materialized_flow( + $table_name, + $columns, + $tokens, + $select_start, + $select_end, + $conflict_indexes, + $conflict_target['parts'], + $conflict_sql + ); + if ( null === $materialized_flow ) { + return null; + } + + $upsert_query = array_merge( $upsert_query, $materialized_flow ); + } + return $upsert_query; + } + private function get_mysql_upsert_select_query( string $table_name, array $columns, string $sql, array $conflict_columns, ?array $conflict_indexes, ?array $value_rows, ?array $insert_id_value_rows, array $explicit_identity_columns, array $extra = array() ): array { + $query = array( + 'action' => 'upsert', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'conflict_columns' => $conflict_columns, + 'inserted_new_row' => null === $value_rows ? true : count( $value_rows ) > 0, + 'value_rows' => $value_rows, + 'insert_id_value_rows' => $insert_id_value_rows, + 'insert_id_unknown' => ! empty( $explicit_identity_columns ), + 'explicit_identity_columns' => $explicit_identity_columns, + ); + if ( null !== $conflict_indexes ) { + $query['conflict_indexes'] = $conflict_indexes; + } + return array_merge( $query, $extra ); + } + private function get_mysql_insert_select_upsert_materialized_flow( string $table_name, array $columns, array $tokens, int $select_start, int $select_end, array $conflict_indexes, array $conflict_parts, string $conflict_sql ): ?array { + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $columns, + array(), + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + $table_context = $this->get_mysql_materialized_dml_table_context( + '__wp_pg_upsert_select_', + '__wp_pg_upsert_select_ord_', + 'upsert-select' . "\0" . $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ) + ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + + $duplicate_conflict_rows_sql = $this->get_mysql_replace_select_duplicate_conflict_rows_sql( + $table_context['source_table_sql'], + $rows_alias, + array( $conflict_indexes ) + ); + if ( null === $duplicate_conflict_rows_sql ) { + return null; + } + + $insert_sql = $this->get_postgresql_dml_insert_from_source_sql( + $table_name, + $columns, + $table_context['source_table_sql'], + $rows_alias, + 'WHERE 1 = 1 ' . $conflict_sql + ); + return $this->get_mysql_materialized_dml_flow( + $select_sql, + $table_context, + array( $insert_sql ), + array( + 'upsert_select_materialized' => true, + 'duplicate_conflict_rows_sql' => $duplicate_conflict_rows_sql, + 'conflict_sql' => $conflict_sql, + 'conflict_parts' => $conflict_parts, + ) + ); + } + private function get_mysql_materialized_dml_table_context( string $table_prefix, string $ordinal_prefix, string $hash_input ): array { + $table_hash = substr( md5( $hash_input ), 0, 12 ); + $table_sql = $this->connection->quote_identifier( $table_prefix . $table_hash ); + return array( + 'source_table_sql' => $table_sql, + 'ordinal_source_table_sql' => $this->connection->quote_identifier( $ordinal_prefix . $table_hash ), + 'drop_sql' => sprintf( 'DROP TABLE IF EXISTS %s', $table_sql ), + ); + } + private function get_mysql_materialized_dml_flow( string $select_sql, array $table_context, array $mutation_statements, array $fields, bool $include_statements = false ): array { + $flow = array( + 'materialize_statements' => array( + $table_context['drop_sql'], + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $table_context['source_table_sql'], $select_sql ), + ), + 'mutation_statements' => $mutation_statements, + 'cleanup_statements' => array( $table_context['drop_sql'] ), + 'source_table_sql' => $table_context['source_table_sql'], + 'ordinal_source_table_sql' => $table_context['ordinal_source_table_sql'], + ); + if ( $include_statements ) { + $flow['statements'] = array_merge( $flow['materialize_statements'], $mutation_statements, $flow['cleanup_statements'] ); + } + return array_merge( $flow, $fields ); + } + private function get_mysql_insert_select_upsert_materialized_flow_for_ambiguous_targets( string $table_name, array $columns, array $tokens, int $select_start, int $select_end, array $candidates, array $assignments, array $assigned_columns ): ?array { + if ( count( $candidates ) < 2 ) { + return null; + } + + $conflict_targets = array(); + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['parts'] as $part ) { + if ( isset( $assigned_columns[ strtolower( (string) ( $part['column'] ?? '' ) ) ] ) ) { + return null; + } + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $conflict_targets[] = $this->get_mysql_upsert_conflict_target_from_candidate( $candidate ); + $conflict_index_groups[] = $conflict_indexes; + } + + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $columns, + array(), + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + $table_context = $this->get_mysql_materialized_dml_table_context( + '__wp_pg_upsert_select_', + '__wp_pg_upsert_select_ord_', + 'upsert-select-ambiguous' . "\0" . $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ) + ); + return $this->get_mysql_materialized_dml_flow( + $select_sql, + $table_context, + array(), + array( + 'upsert_select_materialized' => true, + 'upsert_select_ambiguous_conflict_targets' => true, + 'conflict_targets' => $conflict_targets, + 'conflict_index_groups' => $conflict_index_groups, + 'assignments' => $assignments, + ) + ); + } + private function mysql_dml_column_list_contains_column( array $columns, string $column_name ): bool { + foreach ( $columns as $column ) { + if ( 0 === strcasecmp( (string) $column, $column_name ) ) { + return true; + } + } + return false; + } + private function can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( string $auto_increment_column, array $columns, array $conflict_columns ): bool { + $columns = array_merge( $columns, $conflict_columns ); + return ! $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ); + } + private function get_mysql_insert_select_upsert_literal_value_row( string $table_name, array $columns, array $tokens, int $select_start, int $select_end ): ?array { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + if ( null !== $from_position ) { + if ( + $from_position + 2 !== $select_end + || WP_MySQL_Lexer::DUAL_SYMBOL !== ( $tokens[ $from_position + 1 ]->id ?? null ) + ) { + return null; + } + } + + $projection_end = $from_position ?? $select_end; + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $values = array(); + $insert_id_values = array(); + $probe_safe_values = array(); + foreach ( $projection_ranges as $index => $range ) { + $probe_safe = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $range['start'], $range['end'] ); + $constant_expression = false; + $constant_integer_expression = null; + if ( ! $probe_safe ) { + $constant_expression = $this->is_supported_mysql_upsert_literal_select_expression( $tokens, $range['start'], $range['end'] ); + if ( ! $constant_expression ) { + return null; + } + + $constant_integer_expression = $this->get_mysql_constant_integer_expression_value( $tokens, $range['start'], $range['end'] ); + $probe_safe = true; + } + + $column_key = strtolower( (string) $columns[ $index ] ); + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $range['start'], $range['end'] ); + $insert_id_sql = $projection_sql; + $column_metadata = $target_metadata[ $column_key ] ?? null; + if ( null !== $column_metadata ) { + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $range['start'], + $range['end'], + $projection_sql, + null + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + } + + if ( + $this->is_mysql_auto_increment_column_metadata( $column_metadata ) + ) { + if ( $this->is_mysql_generated_auto_increment_value_sql( $projection_sql ) ) { + $insert_id_sql = $projection_sql; + } else { + if ( null === $constant_integer_expression && $constant_expression ) { + return null; + } + + if ( null !== $constant_integer_expression ) { + $insert_id_sql = $constant_integer_expression; + } + } + } + } + + $values[] = $projection_sql; + $insert_id_values[] = $insert_id_sql; + $probe_safe_values[] = $probe_safe; + } + return array( + 'values' => $values, + 'insert_id_values' => $insert_id_values, + 'probe_safe_values' => $probe_safe_values, + ); + } + private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count, array &$probe_safe_rows, array &$value_range_rows ): ?array { + $rows = array(); + $probe_safe_rows = array(); + $value_range_rows = array(); + + while ( $position < $end ) { + $probe_safe_values = array(); + $parsed_values = $this->parse_mysql_value_list_with_probe_safety( $tokens, $position, $probe_safe_values ); + if ( null === $parsed_values || count( $parsed_values['values'] ) !== $expected_count ) { + return null; + } + + $rows[] = $parsed_values['values']; + $probe_safe_rows[] = $probe_safe_values; + $value_range_rows[] = $parsed_values['ranges']; + + if ( + $position === $end + || WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + return $rows; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + } + return count( $rows ) > 0 ? $rows : null; + } + private function get_postgresql_dml_values_rows_sql( array $value_rows ): string { + $sql_rows = array(); + foreach ( $value_rows as $values ) { + $sql_rows[] = '(' . implode( ', ', $values ) . ')'; + } + return implode( ', ', $sql_rows ); + } + private function parse_mysql_upsert_values_alias_clause( array $tokens, int &$position, int $end, array $columns ): ?array { + if ( $position === $end ) { + return array(); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $row_alias = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $row_alias ) { + return null; + } + + $position += 2; + + $qualified = array(); + foreach ( $columns as $column ) { + $qualified[ strtolower( $column ) ] = $column; + } + + $unqualified = array(); + if ( $position < $end && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $column_aliases = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $column_aliases || count( $column_aliases ) !== count( $columns ) ) { + return null; + } + + $seen_aliases = array(); + foreach ( $column_aliases as $index => $column_alias ) { + $alias_key = strtolower( $column_alias ); + if ( isset( $seen_aliases[ $alias_key ] ) ) { + return null; + } + + $seen_aliases[ $alias_key ] = true; + $qualified[ $alias_key ] = $columns[ $index ]; + $unqualified[ $alias_key ] = $columns[ $index ]; + } + } + + if ( $position !== $end ) { + return null; + } + return array( + 'row' => strtolower( $row_alias ), + 'qualified' => $qualified, + 'unqualified' => $unqualified, + ); + } + private function parse_mysql_value_list_with_probe_safety( array $tokens, int &$position, array &$probe_safety ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = array(); + $ranges = array(); + $probe_safety = array(); + $value_start = $position; + $depth = 0; + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( 0 === $depth ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + ++$position; + return array( + 'values' => $values, + 'ranges' => $ranges, + ); + } + + --$depth; + ++$position; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + $value_start = $position + 1; + } + + ++$position; + } + return null; + } + 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, self::MYSQL_UPSERT_CONFLICT_PROBE_LITERAL_TOKENS, true ); + } + private function is_supported_mysql_upsert_literal_select_expression( array $tokens, int $start, int $end ): bool { + if ( $start >= $end ) { + return false; + } + + for ( $position = $start; $position < $end; $position++ ) { + if ( + null !== $this->get_mysql_identifier_token_value( $tokens[ $position ] ) + || in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL ), true ) + ) { + return false; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $position ] ) ) { + return false; + } + } + return true; + } + private function get_mysql_upsert_conflict_target( + string $table_name, + array $columns, + ?array $value_rows = null, + ?array $probe_safe_rows = null, + ?array $unique_index_metadata_rows = null + ): ?array { + $insert_columns = array_map( 'strtolower', $columns ); + sort( $insert_columns, SORT_STRING ); + + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $cache_key = $table_schema . "\0" . $table_name . "\0" . serialize( $insert_columns ); + if ( array_key_exists( $cache_key, $this->mysql_upsert_conflict_target_cache ) ) { + $cached = $this->mysql_upsert_conflict_target_cache[ $cache_key ]; + return null === $cached ? null : $cached; + } + + $candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns, false, $unique_index_metadata_rows ); + + if ( 1 !== count( $candidates ) ) { + if ( null !== $value_rows && null !== $probe_safe_rows && count( $candidates ) > 1 ) { + return $this->get_mysql_upsert_conflict_target_for_value_rows( + $table_name, + $columns, + $candidates, + $value_rows, + $probe_safe_rows + ); + } + return null; + } + + $conflict_target = $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[0] ); + + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = $conflict_target; + return $conflict_target; + } + private function get_mysql_upsert_write_plan( string $table_name, array $columns, ?array $value_rows = null, ?array $probe_safe_rows = null, array $options = array() ): ?array { + $conflict_target = $this->get_mysql_upsert_conflict_target( $table_name, $columns, $value_rows, $probe_safe_rows ); + $uses_omitted_conflict_target = false; + + if ( + null === $conflict_target + && ! empty( $options['allow_omitted_conflict_target'] ) + && null !== $value_rows + && 1 === count( $value_rows ) + ) { + $table_column_lookup = isset( $options['table_column_lookup'] ) && is_array( $options['table_column_lookup'] ) + ? $options['table_column_lookup'] + : $this->get_mysql_dml_column_metadata_lookup( $table_name ); + if ( null === $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ) ) { + $conflict_target = $this->get_mysql_upsert_omitted_column_conflict_target( $table_name, $columns ); + $uses_omitted_conflict_target = null !== $conflict_target; + } + } + + if ( null === $conflict_target ) { + if ( ! empty( $options['allow_ambiguous_conflict_candidates'] ) ) { + $candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns ); + if ( count( $candidates ) < 2 ) { + return null; + } + + return array( + 'conflict_target' => null, + 'conflict_columns' => $this->get_mysql_upsert_conflict_candidate_columns( $candidates ), + 'conflict_indexes' => null, + 'uses_omitted_conflict_target' => false, + 'ambiguous_conflict_candidates' => $candidates, + ); + } + + return ! empty( $options['allow_unresolved_conflict_target'] ) + ? array( + 'conflict_target' => null, + 'conflict_columns' => array(), + 'conflict_indexes' => null, + 'uses_omitted_conflict_target' => false, + 'ambiguous_conflict_candidates' => array(), + ) + : null; + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ); + if ( null === $conflict_indexes && ! $uses_omitted_conflict_target ) { + return null; + } + + return array( + 'conflict_target' => $conflict_target, + 'conflict_columns' => $conflict_target['columns'], + 'conflict_indexes' => $conflict_indexes, + 'uses_omitted_conflict_target' => $uses_omitted_conflict_target, + 'ambiguous_conflict_candidates' => array(), + ); + } + private function get_mysql_upsert_omitted_column_conflict_target( string $table_name, array $columns ): ?array { + $insert_column_lookup = $this->get_mysql_upsert_insert_column_lookup( $columns ); + + $omitted_candidates = array(); + foreach ( $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns, true ) as $candidate ) { + foreach ( $candidate['columns'] as $column ) { + if ( ! isset( $insert_column_lookup[ strtolower( (string) $column ) ] ) ) { + $omitted_candidates[] = $candidate; + continue 2; + } + } + } + + if ( 1 !== count( $omitted_candidates ) ) { + return null; + } + return $this->get_mysql_upsert_conflict_target_from_candidate( $omitted_candidates[0] ); + } + private function get_mysql_upsert_conflict_target_candidates( string $table_name, array $columns, bool $allow_omitted_columns = false, ?array $unique_index_metadata_rows = null ): array { + $insert_column_lookup = $this->get_mysql_upsert_insert_column_lookup( $columns ); + + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + return $this->get_mysql_upsert_conflict_target_candidates_from_rows( + null === $unique_index_metadata_rows ? $this->get_mysql_unique_index_metadata_rows( $table_schema, $table_name ) : $unique_index_metadata_rows, + $insert_column_lookup, + $allow_omitted_columns + ); + } + private function get_mysql_upsert_insert_column_lookup( array $columns ): array { + return array_fill_keys( array_map( 'strtolower', array_map( 'strval', $columns ) ), true ); + } + private function get_mysql_unique_index_metadata_rows( string $table_schema, string $table_name ): array { + $cache_key = $table_schema . "\0" . $table_name; + if ( array_key_exists( $cache_key, $this->mysql_unique_index_metadata_introspection_cache ) ) { + return $this->mysql_unique_index_metadata_introspection_cache[ $cache_key ]; + } + + $rows = array(); + $index_rows = $this->get_show_create_table_metadata_rows( 'indexes', $table_schema, $table_name, false ); + + foreach ( $index_rows as $row ) { + if ( '0' !== (string) ( $row['non_unique'] ?? '' ) ) { + continue; + } + + $rows[] = array( + 'key_name' => $row['key_name'], + 'column_name' => $row['column_name'], + 'index_type' => $row['index_type'], + 'sub_part' => $row['sub_part'], + ); + } + $this->mysql_unique_index_metadata_introspection_cache[ $cache_key ] = $rows; + return $rows; + } + private function get_mysql_upsert_conflict_target_candidates_from_rows( array $rows, array $insert_column_lookup, bool $allow_omitted_columns ): array { + $candidates = array(); + foreach ( $this->get_mysql_unique_index_groups_from_metadata_rows( $rows ) as $index ) { + if ( empty( $index['columns'] ) ) { + continue; + } + + if ( $this->is_mysql_metadata_only_index_type( $index['index_type'] ) ) { + continue; + } + + foreach ( $index['columns'] as $column ) { + if ( ! $allow_omitted_columns && ! isset( $insert_column_lookup[ strtolower( $column ) ] ) ) { + continue 2; + } + } + + $candidates[] = array( + 'columns' => $index['columns'], + 'parts' => $index['parts'], + ); + } + return $candidates; + } + private function get_mysql_upsert_conflict_candidate_columns( array $candidates ): array { + $columns = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['columns'] ?? array() as $column ) { + $column_key = strtolower( (string) $column ); + if ( isset( $columns[ $column_key ] ) ) { + continue; + } + + $columns[ $column_key ] = (string) $column; + } + } + return array_values( $columns ); + } + private function get_mysql_upsert_conflict_target_for_value_rows( + string $table_name, + array $columns, + array $candidates, + array $value_rows, + array $probe_safe_rows + ): ?array { + $conflicting_candidates = array(); + + foreach ( $candidates as $candidate ) { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $candidate_conflicts = false; + foreach ( $value_rows as $row_index => $values ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $conflict_indexes ) ) { + return null; + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + if ( $conflict_exists ) { + $candidate_conflicts = true; + } + } + + if ( $candidate_conflicts ) { + $conflicting_candidates[] = $candidate; + } + } + + if ( 1 === count( $conflicting_candidates ) ) { + return $this->get_mysql_upsert_conflict_target_from_candidate( $conflicting_candidates[0] ); + } + + if ( 0 === count( $conflicting_candidates ) ) { + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + $conflict_index_groups[] = $conflict_indexes; + } + if ( $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $conflict_index_groups ) ) { + return null; + } + return $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[0] ); + } + return null; + } + private function get_mysql_per_row_upsert_statements_for_ambiguous_targets( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $assignments, array $assigned_columns ): ?array { + $candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns ); + if ( count( $candidates ) < 2 ) { + return null; + } + + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['parts'] as $part ) { + if ( isset( $assigned_columns[ strtolower( (string) ( $part['column'] ?? '' ) ) ] ) ) { + return null; + } + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $conflict_index_groups[] = $conflict_indexes; + } + + $column_sql = $this->get_postgresql_dml_column_list_sql( $columns ); + $inserted_value_rows = array(); + $statements = array(); + $seen_inserted_values = array(); + $used_columns = array(); + + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + $matching_indexes = array(); + + foreach ( $conflict_index_groups as $candidate_index => $conflict_indexes ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safety, $conflict_indexes ) ) { + return null; + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + $seen_conflict = null !== $seen_key && isset( $seen_inserted_values[ $candidate_index ][ $seen_key ] ); + if ( $conflict_exists || $seen_conflict ) { + $matching_indexes[] = $candidate_index; + } + } + + if ( count( $matching_indexes ) > 1 ) { + return null; + } + + $target_index = 1 === count( $matching_indexes ) ? $matching_indexes[0] : 0; + $conflict_target = $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[ $target_index ] ); + foreach ( $conflict_target['columns'] as $column ) { + $used_columns[ strtolower( $column ) ] = $column; + } + + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s', + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + $column_sql, + implode( ', ', $values ), + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + + if ( 0 === count( $matching_indexes ) ) { + $inserted_value_rows[] = $values; + foreach ( $conflict_index_groups as $candidate_index => $conflict_indexes ) { + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + if ( null !== $seen_key ) { + $seen_inserted_values[ $candidate_index ][ $seen_key ] = true; + } + } + } + } + return array( + 'statements' => $statements, + 'inserted_value_rows' => $inserted_value_rows, + 'conflict_columns' => array_values( $used_columns ), + 'conflict_index_groups' => $conflict_index_groups, + ); + } + private function get_mysql_upsert_conflict_target_from_candidate( array $candidate ): array { + $conflict_target = array( + 'columns' => array_values( $candidate['columns'] ), + 'parts' => array_values( $candidate['parts'] ), + 'sql' => array(), + ); + foreach ( $conflict_target['parts'] as $part ) { + $conflict_target['sql'][] = $this->get_mysql_index_key_part_sql( $part['column'], $part['sub_part'] ); + } + return $conflict_target; + } + private function get_mysql_upsert_inserted_value_rows( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_parts ): ?array { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_parts ); + if ( null === $conflict_indexes ) { + return null; + } + + $inserted_rows = array(); + foreach ( $value_rows as $row_index => $values ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $conflict_indexes ) ) { + 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; + } + private function get_mysql_upsert_conflict_indexes( array $columns, array $conflict_parts ): ?array { + $column_indexes = array(); + foreach ( $columns as $index => $column ) { + $column_indexes[ strtolower( $column ) ] = $index; + } + + $conflict_indexes = array(); + foreach ( $conflict_parts as $part ) { + $column = (string) ( $part['column'] ?? '' ); + $column_key = strtolower( $column ); + if ( ! isset( $column_indexes[ $column_key ] ) ) { + return null; + } + + $conflict_indexes[] = array( + 'column' => $column, + 'index' => $column_indexes[ $column_key ], + 'sub_part' => $part['sub_part'] ?? null, + ); + } + return $conflict_indexes; + } + private function mysql_upsert_conflict_indexes_are_probe_safe_for_row( array $values, array $probe_safety, array $conflict_indexes ): bool { + foreach ( $conflict_indexes as $conflict_index ) { + if ( + ! array_key_exists( $conflict_index['index'], $values ) + || ! isset( $probe_safety[ $conflict_index['index'] ] ) + || ! $probe_safety[ $conflict_index['index'] ] + ) { + return false; + } + } + return true; + } + private function get_mysql_conflict_index_value_comparison_sql( array $conflict_index, string $value_sql ): string { + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + return sprintf( + '%s = SUBSTR(CAST(%s AS text), 1, %d)', + $this->get_mysql_index_key_part_sql( (string) $conflict_index['column'], $conflict_index['sub_part'] ), + $value_sql, + (int) $conflict_index['sub_part'] + ); + } + return sprintf( + '%s = %s', + $this->connection->quote_identifier( (string) $conflict_index['column'] ), + $value_sql + ); + } + private function mysql_upsert_conflict_exists( string $table_name, array $values, array $conflict_indexes ): ?bool { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! array_key_exists( $conflict_index['index'], $values ) ) { + return null; + } + + $value = (string) $values[ $conflict_index['index'] ]; + if ( $this->is_mysql_generated_auto_increment_value_sql( $value ) ) { + return false; + } + + $where[] = $this->get_mysql_conflict_index_value_comparison_sql( $conflict_index, $value ); + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE %s LIMIT 1', + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + implode( ' AND ', $where ) + ) + ); + return false !== $stmt->fetchColumn(); + } + private function get_mysql_upsert_last_insert_id_query_fields( string $table_name, array $assignment_effects, ?array $literal_value_row, ?array $value_rows, ?array $probe_safe_rows, ?array $conflict_indexes, bool $has_inserted_rows ) { + if ( ! isset( $assignment_effects['last_insert_id_column'] ) ) { + return array(); + } + + if ( null === $conflict_indexes ) { + return false; + } + + $column_name = (string) $assignment_effects['last_insert_id_column']; + if ( null !== $literal_value_row ) { + $row = $this->get_mysql_upsert_conflicting_row_column_value( + $table_name, + $column_name, + $literal_value_row['values'], + $literal_value_row['probe_safe_values'], + $conflict_indexes + ); + if ( null === $row ) { + return false; + } + return $row['found'] + ? array( 'last_insert_id_on_duplicate_key_update' => $row['value'] ) + : array(); + } + + if ( null === $value_rows || null === $probe_safe_rows ) { + return array( 'last_insert_id_column_on_duplicate_key_update' => $column_name ); + } + + $row = $this->get_mysql_upsert_last_insert_id_row_for_value_rows( + $table_name, + $column_name, + $value_rows, + $probe_safe_rows, + $conflict_indexes, + $has_inserted_rows + ); + if ( null === $row ) { + return false; + } + return $row['found'] + ? array( 'last_insert_id_on_duplicate_key_update' => $row['value'] ) + : array(); + } + private function get_mysql_upsert_last_insert_id_row_for_value_rows( string $table_name, string $column_name, array $value_rows, array $probe_safe_rows, array $conflict_indexes, bool $has_inserted_rows ): ?array { + $found = false; + $value = null; + foreach ( $value_rows as $row_index => $values ) { + $row = $this->get_mysql_upsert_conflicting_row_column_value( + $table_name, + $column_name, + $values, + $probe_safe_rows[ $row_index ] ?? array(), + $conflict_indexes + ); + if ( null === $row ) { + return null; + } + + if ( ! $row['found'] ) { + continue; + } + + $found = true; + $value = $row['value']; + } + + if ( $found && $has_inserted_rows ) { + return null; + } + return array( + 'found' => $found, + 'value' => $value, + ); + } + private function get_mysql_upsert_conflicting_row_column_value( string $table_name, string $column_name, array $values, array $probe_safety, array $conflict_indexes ): ?array { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safety, $conflict_indexes ) ) { + return null; + } + + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $value = (string) $values[ $conflict_index['index'] ]; + if ( $this->is_mysql_generated_auto_increment_value_sql( $value ) ) { + return array( + 'found' => false, + 'value' => null, + ); + } + + $where[] = $this->get_mysql_conflict_index_value_comparison_sql( $conflict_index, $value ); + } + + if ( empty( $where ) ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT %s FROM %s WHERE %s LIMIT 1', + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + implode( ' AND ', $where ) + ) + ); + $value = $stmt->fetchColumn(); + return array( + 'found' => false !== $value, + 'value' => false === $value ? null : $value, + ); + } + private function translate_simple_mysql_replace_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $position = 1; + $header = $this->parse_mysql_replace_table_header( $tokens, $position ); + if ( null === $header ) { + return null; + } + $table_name = $header['table']; + $table_reference_start = $header['start']; + $table_reference_end = $header['end']; + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $column_metadata = null; + $source = $this->parse_mysql_insert_like_dml_source( $table_name, $tokens, $position, $statement_end, $column_metadata ); + if ( null === $source ) { + return null; + } + $columns = $source['columns']; + $value_rows = $source['value_rows']; + $value_range_rows = $source['value_range_rows']; + $probe_safe_rows = $source['probe_safe_rows']; + $insert_column_list = $source['insert_column_list']; + + if ( $this->is_mysql_dml_select_source_token( $tokens[ $position ] ?? null ) ) { + return $this->translate_simple_mysql_replace_select_query( + $query, + $table_name, + $columns, + $tokens, + $position, + $table_reference_start, + $table_reference_end, + $insert_column_list + ); + } + + $column_metadata = $this->normalize_mysql_dml_value_rows_for_columns( + $table_name, + $columns, + $value_rows, + $value_range_rows, + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_value_rows( $table_name, $columns, $value_rows, $column_metadata ); + + $sql = $this->get_postgresql_dml_insert_values_sql( + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + $columns, + $value_rows + ); + + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $unique_index_metadata_rows = $this->get_mysql_unique_index_metadata_rows( $table_schema, $table_name ); + $unique_index_groups = $this->get_mysql_unique_index_groups_from_metadata_rows( $unique_index_metadata_rows ); + $conflict_target = $this->get_mysql_replace_conflict_target( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $unique_index_metadata_rows, + $unique_index_groups + ); + $delete_conflict_index_groups = $this->get_mysql_replace_delete_conflict_index_groups( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $unique_index_groups + ); + $all_delete_conflict_index_groups = $this->get_mysql_replace_delete_conflict_index_groups( $table_name, $columns, array(), array(), $unique_index_groups ); + if ( + null !== $conflict_target + && count( $all_delete_conflict_index_groups ) > count( $delete_conflict_index_groups ) + ) { + $materialized_values_flow = $this->get_mysql_replace_values_delete_then_insert_flow( + $table_name, + $columns, + $value_rows, + $conflict_target, + $all_delete_conflict_index_groups + ); + if ( null !== $materialized_values_flow ) { + return array_merge( + $this->get_mysql_replace_query( + $table_name, + $columns, + $value_rows, + null, + $conflict_target, + array( + 'conflict_probe_safe_rows' => $probe_safe_rows, + ) + ), + $materialized_values_flow + ); + } + } + if ( null === $conflict_target ) { + if ( ! empty( $delete_conflict_index_groups ) ) { + $delete_insert_statements = $this->get_mysql_replace_delete_then_insert_statements( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $delete_conflict_index_groups, + $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $delete_conflict_index_groups ) + ); + if ( null !== $delete_insert_statements ) { + return $this->get_mysql_replace_query( + $table_name, + $columns, + $value_rows, + $sql, + null, + array( + 'statements' => $delete_insert_statements, + 'conflict_probe_safe_rows' => $probe_safe_rows, + 'delete_then_insert' => true, + ) + ); + } + } + return $this->get_mysql_replace_query( + $table_name, + $columns, + $value_rows, + $sql, + null, + array( + 'conflict_value' => null, + ) + ); + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + if ( empty( $delete_conflict_index_groups ) ) { + $delete_conflict_index_groups = array( $conflict_indexes ); + } + + $conflict_sql = $this->get_postgresql_dml_conflict_update_sql( $conflict_target, $this->get_postgresql_dml_excluded_assignments( $columns ) ); + $replace_query = $this->get_mysql_replace_query( + $table_name, + $columns, + $value_rows, + $sql . ' ' . $conflict_sql, + $conflict_target, + array( + 'conflict_indexes' => $conflict_indexes, + 'conflict_probe_safe_rows' => $probe_safe_rows, + ) + ); + + $has_duplicate_conflict_rows = $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $delete_conflict_index_groups ); + $delete_insert_statements = $this->get_mysql_replace_delete_then_insert_statements( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $delete_conflict_index_groups, + $has_duplicate_conflict_rows + ); + if ( null !== $delete_insert_statements ) { + $replace_query['statements'] = $delete_insert_statements; + $replace_query['delete_then_insert'] = true; + $replace_query['inserted_new_row'] = true; + } elseif ( $has_duplicate_conflict_rows ) { + $replace_query['statements'] = $this->get_postgresql_dml_insert_value_row_statements( $table_name, $columns, $value_rows, $conflict_sql ); + } + return $replace_query; + } + private function get_mysql_replace_query( string $table_name, array $columns, ?array $value_rows, ?string $sql, ?array $conflict_target, array $extra = array() ): array { + $query = array( + 'action' => 'replace', + 'table_name' => $table_name, + 'columns' => $columns, + 'conflict_column' => null === $conflict_target ? null : ( $conflict_target['columns'][0] ?? null ), + 'inserted_new_row' => true, + ); + foreach ( compact( 'value_rows', 'sql', 'conflict_target' ) as $field => $value ) { + if ( null !== $value ) { + $query[ $field ] = $value; + } + } + return array_merge( $query, $extra ); + } + private function get_mysql_replace_delete_then_insert_statements( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_index_groups, bool $sequential_statements ): ?array { + $quoted_table = $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ); + + $row_predicates = array(); + foreach ( $value_rows as $row_index => $values ) { + $predicate = $this->get_mysql_replace_delete_predicate_for_row( + $values, + $probe_safe_rows[ $row_index ] ?? array(), + $conflict_index_groups + ); + if ( false === $predicate ) { + return null; + } + + $row_predicates[ $row_index ] = $predicate; + } + + $statements = array(); + if ( $sequential_statements ) { + foreach ( $value_rows as $row_index => $values ) { + if ( null !== $row_predicates[ $row_index ] ) { + $statements[] = sprintf( + 'DELETE FROM %s WHERE %s', + $quoted_table, + $row_predicates[ $row_index ] + ); + } + + $statements[] = $this->get_postgresql_dml_insert_values_sql( $quoted_table, $columns, array( $values ) ); + } + return $statements; + } + + $delete_predicates = array_values( + array_filter( + $row_predicates, + static function ( $predicate ): bool { + return null !== $predicate; + } + ) + ); + if ( ! empty( $delete_predicates ) ) { + $statements[] = sprintf( + 'DELETE FROM %s WHERE %s', + $quoted_table, + $this->get_mysql_parenthesized_or_predicate_sql( $delete_predicates ) + ); + } + + $statements[] = $this->get_postgresql_dml_insert_values_sql( $quoted_table, $columns, $value_rows ); + return $statements; + } + + /** Render a PostgreSQL INSERT ... VALUES statement. */ + private function get_postgresql_dml_insert_values_sql( string $table_sql, array $columns, array $value_rows, string $suffix = '' ): string { + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $table_sql, + $this->get_postgresql_dml_column_list_sql( $columns ), + $this->get_postgresql_dml_values_rows_sql( $value_rows ) + ); + return '' === $suffix ? $sql : $sql . ' ' . $suffix; + } + private function get_postgresql_dml_insert_value_row_statements( string $table_name, array $columns, array $value_rows, string $suffix = '' ): array { + $statements = array(); + $table_sql = $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ); + foreach ( $value_rows as $values ) { + $statements[] = $this->get_postgresql_dml_insert_values_sql( $table_sql, $columns, array( $values ), $suffix ); + } + return $statements; + } + private function get_postgresql_dml_insert_from_source_sql( string $table_name, array $columns, string $source_table_sql, string $rows_alias, string $suffix = '' ): string { + $sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s', + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + $this->get_postgresql_dml_column_list_sql( $columns ), + $this->get_postgresql_dml_qualified_column_list_sql( $rows_alias, $columns ), + $source_table_sql, + $rows_alias + ); + return '' === $suffix ? $sql : $sql . ' ' . $suffix; + } + + /** Render a PostgreSQL DML column list. */ + private function get_postgresql_dml_column_list_sql( array $columns ): string { + return implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ); + } + private function get_postgresql_dml_qualified_column_list_sql( string $alias, array $columns ): string { + return array() === $columns ? '' : $alias . '.' . implode( ', ' . $alias . '.', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ); + } + private function get_postgresql_dml_excluded_assignments( array $columns ): array { + $columns = array_map( array( $this->connection, 'quote_identifier' ), $columns ); + return array_map( 'sprintf', array_fill( 0, count( $columns ), '%s = excluded.%s' ), $columns, $columns ); + } + private function get_postgresql_dml_conflict_update_sql( array $conflict_target, array $assignments ): string { + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + } + private function get_mysql_replace_delete_predicate_for_row( array $values, array $probe_safety, array $conflict_index_groups ) { + $predicates = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $predicate = $this->get_mysql_replace_delete_predicate_for_row_conflict_indexes( + $values, + $probe_safety, + $conflict_indexes + ); + if ( false === $predicate ) { + return false; + } + + if ( null !== $predicate ) { + $predicates[] = $predicate; + } + } + + if ( empty( $predicates ) ) { + return null; + } + + if ( 1 === count( $predicates ) ) { + return $predicates[0]; + } + return $this->get_mysql_parenthesized_or_predicate_sql( $predicates ); + } + private function get_mysql_parenthesized_or_predicate_sql( array $predicates ): string { + return implode( + ' OR ', + array_map( + static function ( string $predicate ): string { + return '(' . $predicate . ')'; + }, + $predicates + ) + ); + } + private function get_mysql_replace_delete_predicate_for_row_conflict_indexes( array $values, array $probe_safety, array $conflict_indexes ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safety, $conflict_indexes ) ) { + return false; + } + + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $value = trim( (string) $values[ $conflict_index['index'] ] ); + if ( $this->is_mysql_replace_conflict_ignored_value_sql( $value ) ) { + return null; + } + + $where[] = $this->get_mysql_conflict_index_value_comparison_sql( $conflict_index, $value ); + } + return empty( $where ) ? null : implode( ' AND ', $where ); + } + private function get_mysql_replace_delete_conflict_index_groups( string $table_name, array $columns, array $value_rows = array(), array $probe_safe_rows = array(), ?array $unique_index_groups = null ): array { + if ( null === $unique_index_groups ) { + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $unique_index_groups = $this->get_mysql_unique_index_groups_from_metadata_rows( $this->get_mysql_unique_index_metadata_rows( $table_schema, $table_name ) ); + } + + $conflict_index_groups = array(); + foreach ( $unique_index_groups as $index ) { + if ( empty( $index['parts'] ) || $this->is_mysql_metadata_only_index_type( $index['index_type'] ) ) { + continue; + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $index['parts'] ); + if ( null === $conflict_indexes ) { + continue; + } + + if ( ! $this->mysql_replace_conflict_indexes_are_probe_safe_for_rows( $conflict_indexes, $value_rows, $probe_safe_rows ) ) { + continue; + } + + $conflict_index_groups[] = $conflict_indexes; + } + return $conflict_index_groups; + } + private function mysql_replace_conflict_indexes_are_probe_safe_for_rows( array $conflict_indexes, array $value_rows, array $probe_safe_rows ): bool { + foreach ( $value_rows as $row_index => $values ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $conflict_indexes ) ) { + return false; + } + } + return true; + } + private function translate_simple_mysql_replace_select_query( + string $query, + string $table_name, + array $columns, + array $tokens, + int $position, + int $table_reference_start, + int $table_reference_end, + bool $insert_column_list = false + ): ?array { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $select_columns = $columns; + $default_columns = $this->get_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns ); + $rewrite = $this->get_mysql_insert_select_rewrite_data( + $query, + $table_name, + $columns, + $tokens, + $position, + $statement_end, + $table_reference_start, + $table_reference_end, + $insert_column_list, + $default_columns, + '__wp_pg_replace_source' + ); + if ( null === $rewrite ) { + return null; + } + $columns = $rewrite['columns']; + $select_start = $rewrite['select_start']; + $select_end = $rewrite['select_end']; + $sql = $rewrite['sql']; + + $replace_select_value_rows = null; + $replace_select_probe_safe_rows = null; + $replace_select_literal_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $select_columns, + $tokens, + $select_start, + $select_end + ); + if ( null !== $replace_select_literal_row && empty( $default_columns ) ) { + $replace_select_value_rows = array( $replace_select_literal_row['values'] ); + $replace_select_probe_safe_rows = array( $replace_select_literal_row['probe_safe_values'] ); + } + + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $unique_index_metadata_rows = $this->get_mysql_unique_index_metadata_rows( $table_schema, $table_name ); + $unique_index_groups = $this->get_mysql_unique_index_groups_from_metadata_rows( $unique_index_metadata_rows ); + $conflict_target = $this->get_mysql_replace_conflict_target( + $table_name, + $columns, + $replace_select_value_rows, + $replace_select_probe_safe_rows, + $unique_index_metadata_rows, + $unique_index_groups + ); + $replace_select_flow = $this->get_mysql_replace_select_delete_then_insert_flow( + $table_name, + $columns, + $select_columns, + $default_columns, + $conflict_target, + $tokens, + $select_start, + $select_end, + $unique_index_groups + ); + $replace_query = null; + if ( null !== $replace_select_flow ) { + $conflict_target = $conflict_target ?? array( + 'columns' => array(), + 'parts' => array(), + 'sql' => array(), + ); + + $sql = $replace_select_flow['sql']; + $replace_query = $replace_select_flow; + } + + return $this->get_mysql_replace_query( + $table_name, + $columns, + null, + $sql, + $conflict_target, + array_merge( + array( + 'conflict_value' => null, + 'replace_select_affected_rows_sql' => null, + ), + $replace_query ?? array() + ) + ); + } + private function get_mysql_replace_values_delete_then_insert_flow( string $table_name, array $columns, array $value_rows, array $conflict_target, array $conflict_index_groups ): ?array { + if ( empty( $conflict_index_groups ) || empty( $value_rows ) ) { + return null; + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return null; + } + + $select_rows = array(); + foreach ( $value_rows as $row_index => $values ) { + if ( count( $values ) !== count( $columns ) ) { + return null; + } + + $projections = array(); + foreach ( $values as $column_index => $value_sql ) { + $projection = (string) $value_sql; + if ( 0 === $row_index ) { + $projection .= ' AS ' . $this->connection->quote_identifier( $columns[ $column_index ] ); + } + $projections[] = $projection; + } + + $select_rows[] = 'SELECT ' . implode( ', ', $projections ); + } + + return $this->get_mysql_replace_materialized_source_delete_insert_flow( + $table_name, + $columns, + implode( ' UNION ALL ', $select_rows ), + '__wp_pg_replace_values_', + '__wp_pg_replace_values_ord_', + $table_name . "\0" . implode( "\0", $columns ) . "\0" . implode( "\0", array_map( 'implode', $value_rows ) ), + $conflict_indexes, + $conflict_index_groups + ); + } + private function get_mysql_replace_select_delete_then_insert_flow( string $table_name, array $columns, array $select_columns, array $default_columns, ?array $conflict_target, array $tokens, int $select_start, int $select_end, ?array $unique_index_groups = null ): ?array { + $conflict_index_groups = $this->get_mysql_replace_delete_conflict_index_groups( $table_name, $columns, array(), array(), $unique_index_groups ); + $conflict_indexes = null; + if ( null !== $conflict_target ) { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return null; + } + } + + if ( empty( $conflict_index_groups ) && null !== $conflict_indexes ) { + $conflict_index_groups = array( $conflict_indexes ); + } + if ( empty( $conflict_index_groups ) ) { + return null; + } + $conflict_indexes = $conflict_indexes ?? $conflict_index_groups[0]; + + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $select_columns, + $default_columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + return $this->get_mysql_replace_materialized_source_delete_insert_flow( + $table_name, + $columns, + $select_sql, + '__wp_pg_replace_select_', + '__wp_pg_replace_select_ord_', + $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ), + $conflict_indexes, + $conflict_index_groups + ); + } + private function get_mysql_replace_materialized_source_delete_insert_flow( string $table_name, array $columns, string $select_sql, string $table_prefix, string $ordinal_prefix, string $hash_input, array $conflict_indexes, array $conflict_index_groups ): ?array { + if ( empty( $conflict_indexes ) || empty( $conflict_index_groups ) ) { + return null; + } + return $this->get_mysql_replace_materialized_delete_insert_flow( + $table_name, + $columns, + $select_sql, + $this->get_mysql_materialized_dml_table_context( $table_prefix, $ordinal_prefix, $hash_input ), + $conflict_indexes, + $conflict_index_groups + ); + } + private function get_mysql_replace_materialized_delete_insert_flow( string $table_name, array $columns, string $select_sql, array $table_context, array $conflict_indexes, array $conflict_index_groups ): ?array { + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $quoted_target_table = $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ); + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $conflict_index_groups + ); + if ( null === $delete_predicate_sql ) { + return null; + } + + $affected_rows_count_sql = sprintf( + 'SELECT ((SELECT COUNT(*) FROM %1$s) + (SELECT COUNT(*) FROM %2$s AS %3$s WHERE EXISTS (SELECT 1 FROM %1$s AS %4$s WHERE %5$s))) AS affected_rows, (SELECT COUNT(*) FROM %1$s) AS inserted_rows', + $table_context['source_table_sql'], + $quoted_target_table, + $target_alias, + $rows_alias, + $delete_predicate_sql + ); + $duplicate_conflict_rows_sql = $this->get_mysql_replace_select_duplicate_conflict_rows_sql( + $table_context['source_table_sql'], + $rows_alias, + $conflict_index_groups + ); + if ( null === $duplicate_conflict_rows_sql ) { + return null; + } + + $delete_sql = sprintf( + 'DELETE FROM %s AS %s WHERE EXISTS (SELECT 1 FROM %s AS %s WHERE %s)', + $quoted_target_table, + $target_alias, + $table_context['source_table_sql'], + $rows_alias, + $delete_predicate_sql + ); + $insert_sql = $this->get_postgresql_dml_insert_from_source_sql( + $table_name, + $columns, + $table_context['source_table_sql'], + $rows_alias + ); + return $this->get_mysql_materialized_dml_flow( + $select_sql, + $table_context, + array( $delete_sql, $insert_sql ), + array( + 'sql' => $insert_sql, + 'replace_select_materialized' => true, + 'replace_select_affected_rows_sql' => $affected_rows_count_sql, + 'duplicate_conflict_rows_sql' => $duplicate_conflict_rows_sql, + 'conflict_indexes' => $conflict_indexes, + 'conflict_index_groups' => $conflict_index_groups, + ), + true + ); + } + private function get_mysql_replace_select_source_sql( string $table_name, array $select_columns, array $default_columns, array $tokens, int $select_start, int $select_end ): ?string { + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + $projection_end = $from_position ?? $select_end; + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $select_columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $scope = null; + $replacements = array(); + if ( null !== $from_position ) { + $first_clause_position = $this->find_first_top_level_mysql_token( + $tokens, + self::MYSQL_SELECT_FROM_BOUNDARY_TOKENS, + $from_position + 1, + $select_end + ) ?? $select_end; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $first_clause_position ); + if ( + null !== $scope + && empty( $scope['unknown'] ) + && $this->mysql_scope_references_non_public_schema( $scope ) + ) { + $source_sql = $this->translate_mysql_table_reference_range_to_postgresql( $tokens, $from_position + 1, $first_clause_position ); + if ( null === $source_sql ) { + return null; + } + + $replacements[] = array( + 'start' => $from_position + 1, + 'end' => $first_clause_position, + 'sql' => $source_sql, + ); + } + } + + foreach ( $projection_ranges as $index => $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $expression_start = $expression_bounds['start']; + $expression_end = $expression_bounds['end']; + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ); + $column_metadata = $target_metadata[ strtolower( $select_columns[ $index ] ) ] ?? null; + if ( null !== $column_metadata ) { + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $expression_start, + $expression_end, + $projection_sql, + $scope + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + } + } + + $replacements[] = array( + 'start' => $range['start'], + 'end' => $range['end'], + 'sql' => $projection_sql . ' AS ' . $this->connection->quote_identifier( $select_columns[ $index ] ), + ); + } + + $this->sort_mysql_replacements( $replacements ); + $select_sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $select_start, + $select_end, + $replacements + ); + if ( ! empty( $default_columns ) ) { + $select_sql = $this->append_mysql_select_default_projection_sql( + $select_sql, + $default_columns, + '__wp_pg_replace_rows_source' + ); + } + return $select_sql; + } + private function append_mysql_select_default_projection_sql( string $select_sql, array $default_columns, string $source_alias ): string { + $quoted_source_alias = $this->connection->quote_identifier( $source_alias ); + $projection_sql = array( + $quoted_source_alias . '.*', + ); + + foreach ( $default_columns as $default_column ) { + $projection_sql[] = sprintf( + '%s AS %s', + $default_column['sql'], + $this->connection->quote_identifier( $default_column['column'] ) + ); + } + return sprintf( + 'SELECT %s FROM (%s) AS %s WHERE 1 = 1', + implode( ', ', $projection_sql ), + $select_sql, + $quoted_source_alias + ); + } + private function get_mysql_replace_select_delete_predicate_sql( string $target_alias, string $rows_alias, array $conflict_index_groups ): ?string { + $group_predicates = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $predicate = $this->get_materialized_mysql_dml_conflict_group_predicate_sql( $target_alias, $rows_alias, $conflict_indexes ); + if ( null === $predicate ) { + return null; + } + + $group_predicates[] = '(' . $predicate . ')'; + } + return empty( $group_predicates ) ? null : implode( ' OR ', $group_predicates ); + } + private function get_mysql_replace_select_duplicate_conflict_rows_sql( string $source_table_sql, string $rows_alias, array $conflict_index_groups ): ?string { + $probes = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $key_sql = array(); + $not_null_sql = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $column = (string) ( $conflict_index['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $incoming_column = $this->get_postgresql_dml_qualified_column_list_sql( $rows_alias, array( $column ) ); + $not_null_sql[] = $incoming_column . ' IS NOT NULL'; + $key_sql[] = null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] + ? sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_column, + (int) $conflict_index['sub_part'] + ) + : $incoming_column; + } + + if ( empty( $key_sql ) ) { + return null; + } + + $probes[] = sprintf( + 'SELECT 1 FROM %s AS %s WHERE %s GROUP BY %s HAVING COUNT(*) > 1', + $source_table_sql, + $rows_alias, + implode( ' AND ', $not_null_sql ), + implode( ', ', $key_sql ) + ); + } + return empty( $probes ) ? null : implode( ' UNION ALL ', $probes ) . ' LIMIT 1'; + } + private function get_mysql_replace_return_value( array &$replace_query ): ?int { + if ( + ! isset( $replace_query['table_name'], $replace_query['conflict_column'] ) + || null === $replace_query['conflict_column'] + ) { + return null; + } + + if ( ! empty( $replace_query['delete_then_insert'] ) ) { + return null; + } + + if ( isset( $replace_query['replace_select_affected_rows_sql'] ) && is_string( $replace_query['replace_select_affected_rows_sql'] ) ) { + $stmt = $this->connection->query( $replace_query['replace_select_affected_rows_sql'] ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || ! isset( $row['affected_rows'] ) ) { + return null; + } + + $replace_query['inserted_new_row'] = isset( $row['inserted_rows'] ) && (int) $row['inserted_rows'] > 0; + return (int) $row['affected_rows']; + } + + if ( + ! isset( $replace_query['value_rows'], $replace_query['conflict_indexes'] ) + || ! is_array( $replace_query['value_rows'] ) + || ! is_array( $replace_query['conflict_indexes'] ) + ) { + return null; + } + + $probe_safe_rows = isset( $replace_query['conflict_probe_safe_rows'] ) && is_array( $replace_query['conflict_probe_safe_rows'] ) + ? $replace_query['conflict_probe_safe_rows'] + : array(); + + $return_value = 0; + $inserted_new_row = false; + $seen_values = array(); + foreach ( $replace_query['value_rows'] as $row_index => $values ) { + if ( ! is_array( $values ) ) { + return null; + } + + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $replace_query['conflict_indexes'] ) ) { + return null; + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $replace_query['conflict_indexes'] ); + $replace_conflict_exists = null !== $seen_key && isset( $seen_values[ $seen_key ] ); + if ( ! $replace_conflict_exists ) { + $replace_conflict_exists = $this->mysql_upsert_conflict_exists( + (string) $replace_query['table_name'], + $values, + $replace_query['conflict_indexes'] + ); + if ( null === $replace_conflict_exists ) { + return null; + } + } + + $return_value += $replace_conflict_exists ? 2 : 1; + if ( ! $replace_conflict_exists ) { + $inserted_new_row = true; + } + if ( null !== $seen_key ) { + $seen_values[ $seen_key ] = true; + } + } + + $replace_query['inserted_new_row'] = ! empty( $replace_query['delete_then_insert'] ) ? true : $inserted_new_row; + return $return_value; + } + private function get_mysql_replace_conflict_target( string $table_name, array $columns, ?array $value_rows = null, ?array $probe_safe_rows = null, ?array $unique_index_metadata_rows = null, ?array $unique_index_groups = null ): ?array { + $metadata_target = $this->get_mysql_upsert_conflict_target( $table_name, $columns, $value_rows, $probe_safe_rows, $unique_index_metadata_rows ); + $heuristic_target = $this->get_simple_replace_conflict_target( $table_name, $columns ); + if ( null === $heuristic_target ) { + return $metadata_target; + } + + if ( null === $metadata_target ) { + return $heuristic_target; + } + + if ( + $this->is_mysql_replace_conflict_target_backed_by_unique_metadata( $table_name, $heuristic_target, $unique_index_groups ) + && + null !== $value_rows + && null !== $probe_safe_rows + && ! $this->mysql_replace_conflict_target_has_existing_conflict( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $metadata_target + ) + ) { + return $heuristic_target; + } + return $metadata_target; + } + private function is_mysql_replace_conflict_target_backed_by_unique_metadata( string $table_name, array $conflict_target, ?array $unique_index_groups = null ): bool { + $target_parts = $conflict_target['parts'] ?? array(); + if ( empty( $target_parts ) ) { + return false; + } + + if ( null === $unique_index_groups ) { + $table_schema = $this->get_mysql_unqualified_dml_table_backend_schema( $table_name ); + $unique_index_groups = $this->get_mysql_unique_index_groups_from_metadata_rows( $this->get_mysql_unique_index_metadata_rows( $table_schema, $table_name ) ); + } + + foreach ( $unique_index_groups as $index ) { + if ( $this->is_mysql_metadata_only_index_type( $index['index_type'] ) ) { + continue; + } + + if ( count( $index['parts'] ) !== count( $target_parts ) ) { + continue; + } + + foreach ( $target_parts as $part_index => $part ) { + $index_part = $index['parts'][ $part_index ]; + if ( + 0 !== strcasecmp( (string) ( $index_part['column'] ?? '' ), (string) ( $part['column'] ?? '' ) ) + || (string) ( $index_part['sub_part'] ?? '' ) !== (string) ( $part['sub_part'] ?? '' ) + ) { + continue 2; + } + } + return true; + } + return false; + } + private function get_mysql_unique_index_groups_from_metadata_rows( array $rows ): array { + $indexes = array(); + foreach ( $rows as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'columns' => array(), + 'index_type' => strtoupper( (string) ( $row['index_type'] ?? 'BTREE' ) ), + 'parts' => array(), + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['columns'][] = $column_name; + $indexes[ $key_name ]['parts'][] = array( + 'column' => $column_name, + 'sub_part' => null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ? (string) $row['sub_part'] : null, + ); + } + return $indexes; + } + private function get_simple_replace_conflict_target( string $table_name, array $columns ): ?array { + $conflict_column = $this->get_simple_replace_conflict_column( $table_name, $columns ); + if ( null === $conflict_column ) { + return null; + } + return array( + 'columns' => array( $conflict_column ), + 'parts' => array( + array( + 'column' => $conflict_column, + 'sub_part' => null, + ), + ), + 'sql' => array( $this->connection->quote_identifier( $conflict_column ) ), + ); + } + private function mysql_replace_conflict_target_has_existing_conflict( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_target ): bool { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return true; + } + + foreach ( $value_rows as $row_index => $values ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $conflict_indexes ) ) { + return true; + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists || $conflict_exists ) { + return true; + } + } + return false; + } + private function has_duplicate_mysql_replace_conflict_value_rows( array $value_rows, array $probe_safe_rows, array $conflict_indexes ): bool { + $seen_values = array(); + foreach ( $value_rows as $row_index => $values ) { + if ( ! $this->mysql_upsert_conflict_indexes_are_probe_safe_for_row( $values, $probe_safe_rows[ $row_index ] ?? array(), $conflict_indexes ) ) { + return false; + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + if ( null === $seen_key ) { + continue; + } + + if ( isset( $seen_values[ $seen_key ] ) ) { + return true; + } + + $seen_values[ $seen_key ] = true; + } + return false; + } + private function has_duplicate_mysql_replace_conflict_value_rows_in_groups( array $value_rows, array $probe_safe_rows, array $conflict_index_groups ): bool { + foreach ( $conflict_index_groups as $conflict_indexes ) { + if ( $this->has_duplicate_mysql_replace_conflict_value_rows( $value_rows, $probe_safe_rows, $conflict_indexes ) ) { + return true; + } + } + return false; + } + private function get_mysql_replace_conflict_seen_key_for_row( array $values, array $conflict_indexes ): ?string { + $parts = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! array_key_exists( $conflict_index['index'], $values ) ) { + return null; + } + + $value = trim( (string) $values[ $conflict_index['index'] ] ); + if ( $this->is_mysql_replace_conflict_ignored_value_sql( $value ) ) { + return null; + } + + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $value = $this->get_mysql_replace_conflict_prefix_seen_value( $value, (int) $conflict_index['sub_part'] ); + } + + $parts[] = $value; + } + return implode( "\0", $parts ); + } + private function is_mysql_replace_conflict_ignored_value_sql( string $value_sql ): bool { + return '' === $value_sql + || 'NULL' === strtoupper( $value_sql ) + || $this->is_mysql_generated_auto_increment_value_sql( $value_sql ); + } + private function get_mysql_replace_conflict_prefix_seen_value( string $value_sql, int $length ): string { + if ( $length <= 0 || strlen( $value_sql ) < 2 || "'" !== $value_sql[0] || "'" !== $value_sql[ strlen( $value_sql ) - 1 ] ) { + return $value_sql; + } + + $value = str_replace( "''", "'", substr( $value_sql, 1, -1 ) ); + $count = preg_match_all( '/./us', $value, $matches ); + if ( false !== $count ) { + $value = implode( '', array_slice( $matches[0], 0, $length ) ); + } else { + $value = substr( $value, 0, $length ); + } + return "'" . str_replace( "'", "''", $value ) . "'"; + } + 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 ( explode( ' ', '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; + } + private function translate_simple_mysql_insert_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + $position = 1; + $ignore = false; + $header = $this->parse_mysql_insert_table_header( $tokens, $position, $ignore ); + if ( null === $header ) { + return null; + } + $table_name = $header['table']; + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $column_metadata = null; + $source = $this->parse_mysql_insert_like_dml_source( + $table_name, + $tokens, + $position, + $statement_end, + $column_metadata, + array( + 'allow_select_source' => false, + ) + ); + if ( null === $source ) { + return null; + } + $columns = $source['columns']; + $value_rows = $source['value_rows']; + $value_range_rows = $source['value_range_rows']; + + $column_metadata = $this->normalize_mysql_dml_value_rows_for_columns( + $table_name, + $columns, + $value_rows, + $value_range_rows, + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_value_rows( $table_name, $columns, $value_rows, $column_metadata ); + + $sql = $this->get_postgresql_dml_insert_values_sql( + $this->get_postgresql_unqualified_dml_table_reference_sql( $table_name ), + $columns, + $value_rows + ); + return array( + 'action' => 'insert', + 'sql' => $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $value_rows[0] ?? array(), + 'value_rows' => $value_rows, + 'ignore' => $ignore, + 'inserted_new_row' => true, + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Recording_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Recording_PDO.php new file mode 100644 index 000000000..44f72ffce --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Recording_PDO.php @@ -0,0 +1,99 @@ +pdo = $pdo; + $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..e03afea17 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -0,0 +1,585 @@ + + */ + private $real_pgsql_test_schemas = array(); + + /** + * Drop isolated real PostgreSQL schemas created during the test. + */ + protected function tearDown(): void { + foreach ( array_reverse( $this->real_pgsql_test_schemas ) as $cleanup ) { + try { + $pdo = $cleanup['pdo']; + if ( $pdo->inTransaction() ) { + $pdo->rollBack(); + } + + $pdo->exec( + 'DROP SCHEMA IF EXISTS ' . + WP_PostgreSQL_Connection::quote_identifier_value( $cleanup['schema'] ) . + ' CASCADE' + ); + } catch ( Throwable $e ) { + // Cleanup should not mask the test result. + } + } + + $this->real_pgsql_test_schemas = array(); + parent::tearDown(); + } + + /** + * Tests PostgreSQL DSN construction from structured options. + */ + public function test_build_dsn_from_structured_options(): void { + $this->assertSame( + 'pgsql:host=localhost;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction does not include credentials. + */ + public function test_build_dsn_keeps_credentials_out_of_structured_dsn(): void { + $this->assertSame( + 'pgsql:host=localhost;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'wp', + 'user' => 'wp_user', + 'password' => 'secret', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction requires a database name. + * + * @dataProvider data_missing_dbname_options + * + * @param array $options Connection options. + */ + public function test_build_dsn_requires_non_empty_dbname( array $options ): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( $options ); + } + + /** + * Data provider for missing database-name options. + * + * @return array + */ + public function data_missing_dbname_options(): array { + return array( + 'not set' => array( array() ), + 'empty' => array( array( 'dbname' => '' ) ), + 'null' => array( array( 'dbname' => null ) ), + ); + } + + /** + * Tests empty host and port values are omitted. + */ + public function test_build_dsn_omits_empty_host_and_port(): void { + $this->assertSame( + 'pgsql:dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '', + 'port' => '', + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction preserves socket-style host paths. + */ + public function test_build_dsn_preserves_socket_style_host_paths(): void { + $this->assertSame( + 'pgsql:host=/var/run/postgresql;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '/var/run/postgresql', + 'dbname' => 'wp', + ) + ) + ); + + $this->assertSame( + 'pgsql:host=/tmp/.s.PGSQL.5432;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '/tmp/.s.PGSQL.5432', + 'port' => 5432, + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN separator rejection. + */ + public function test_build_dsn_rejects_structured_option_separators(): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'local;host', + 'dbname' => 'wp', + ) + ); + } + + /** + * Tests PostgreSQL DSN NUL-byte rejection. + * + * @dataProvider data_nul_byte_dsn_options + * + * @param array $options Connection options. + */ + public function test_build_dsn_rejects_nul_bytes_in_structured_options( array $options ): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( $options ); + } + + /** + * Data provider for NUL-containing DSN options. + * + * @return array + */ + public function data_nul_byte_dsn_options(): array { + return array( + 'host' => array( + array( + 'host' => "local\0host", + 'dbname' => 'wp', + ), + ), + 'port' => array( + array( + 'port' => "5432\0", + 'dbname' => 'wp', + ), + ), + 'dbname' => array( + array( + 'dbname' => "w\0p", + ), + ), + ); + } + + /** + * Tests PostgreSQL identifier quoting. + */ + public function test_quote_identifier_value_uses_postgresql_double_quotes(): void { + $this->assertSame( + '"wp_""posts"', + WP_PostgreSQL_Connection::quote_identifier_value( 'wp_"posts' ) + ); + } + + /** + * Tests PostgreSQL identifier NUL-byte rejection. + */ + public function test_quote_identifier_value_rejects_nul_bytes(): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::quote_identifier_value( "wp_\0posts" ); + } + + /** + * Tests injected PDO instances are configured and reused. + */ + public function test_constructor_uses_injected_pdo_and_sets_exception_mode(): void { + $pdo = $this->create_real_pgsql_pdo(); + $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' => $this->create_real_pgsql_pdo() ) ); + $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_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $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_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $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_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $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. + * @param bool $should_succeed Whether PostgreSQL accepts the statement. + */ + public function test_query_wraps_locking_select_statement_in_per_statement_savepoint( string $sql, bool $should_succeed ): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $pdo ); + + $pdo->beginTransaction(); + + if ( $should_succeed ) { + $connection->query( $sql ); + } else { + try { + $connection->query( $sql ); + $this->fail( 'Expected PostgreSQL to reject the raw MySQL locking SELECT shape.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + } + + $pdo->rollBack(); + $this->assertSame( + $should_succeed + ? array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + ) + : 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', true ), + 'for_share' => array( 'SELECT 1 FOR SHARE', true ), + 'lock_in_share_mode' => array( 'SELECT 1 LOCK IN SHARE MODE', false ), + ); + } + + /** + * 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_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $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' => $this->create_real_pgsql_pdo() ) ); + $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_Recording_PDO( $this->create_real_pgsql_pdo() ); + $connection = $this->create_connection_with_recording_pdo( $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 = $this->create_real_pgsql_pdo(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $pdo->exec( 'CREATE TABLE t (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 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 = $this->create_real_pgsql_pdo(); + $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 = new WP_PostgreSQL_Connection( array( 'pdo' => $this->create_real_pgsql_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 = new WP_PostgreSQL_Connection( array( 'pdo' => $this->create_real_pgsql_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 recording PDO proxy. + * + * @param object $recording_pdo PDO-like recording proxy backed by PostgreSQL. + * @return WP_PostgreSQL_Connection Connection under test. + */ + private function create_connection_with_recording_pdo( $recording_pdo ): 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, $recording_pdo ); + + return $connection; + } + + /** + * Create an isolated real PostgreSQL PDO for tests that execute SQL. + * + * @return PDO Real PostgreSQL PDO. + */ + private function create_real_pgsql_pdo(): PDO { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run this real PostgreSQL connection test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $schema = 'wp_pg_connection_test_' . strtolower( bin2hex( random_bytes( 8 ) ) ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $this->real_pgsql_test_schemas[] = array( + 'pdo' => $pdo, + 'schema' => $schema, + ); + $pdo->exec( 'SET search_path TO ' . $schema_sql . ', public' ); + + return $pdo; + } +} 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..6bfaf2917 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php @@ -0,0 +1,960 @@ +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\" __wp_mysql_longtext 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 original CREATE TABLE statements are extracted for driver-owned install DDL. + */ + public function test_extract_create_table_statements_preserves_original_create_sql(): void { + $sql = "CREATE TABLE wp_options ( + option_id bigint(20) unsigned NOT NULL auto_increment, + option_name varchar(191) NOT NULL default '', + PRIMARY KEY (option_id) + ) DEFAULT CHARACTER SET utf8mb4; + SELECT 1; + CREATE TABLE wp_posts ( + ID bigint(20) unsigned NOT NULL auto_increment, + post_title text NOT NULL, + PRIMARY KEY (ID) + ) DEFAULT CHARACTER SET utf8mb4;"; + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $statements = $translator->extract_create_table_statements( $sql ); + + $this->assertCount( 2, $statements ); + $this->assertStringStartsWith( 'CREATE TABLE wp_options', $statements[0] ); + $this->assertStringContainsString( 'PRIMARY KEY (option_id)', $statements[0] ); + $this->assertStringNotContainsString( 'SELECT 1', implode( "\n", $statements ) ); + $this->assertStringNotContainsString( ';', implode( "\n", $statements ) ); + $this->assertStringStartsWith( 'CREATE TABLE wp_posts', $statements[1] ); + $this->assertStringContainsString( 'PRIMARY KEY (ID)', $statements[1] ); + } + + /** + * 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\" __wp_mysql_bigint_20_unsigned NOT NULL DEFAULT '0',\n \"term_taxonomy_id\" __wp_mysql_bigint_20_unsigned NOT NULL DEFAULT '0',\n \"term_order\" __wp_mysql_int_11 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 MySQL integer aliases are translated while preserving their metadata shape. + */ + public function test_translate_integer_aliases_create_table(): void { + $sql = 'CREATE TABLE wp_integer_aliases ( + tiny_value int1, + small_value int2, + medium_value int3, + regular_value int4 unsigned, + huge_value int8, + next_id int4 NOT NULL AUTO_INCREMENT, + PRIMARY KEY (next_id) + )'; + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_integer_aliases\" (\n \"tiny_value\" __wp_mysql_int1,\n \"small_value\" __wp_mysql_int2,\n \"medium_value\" __wp_mysql_int3,\n \"regular_value\" __wp_mysql_int4_unsigned,\n \"huge_value\" __wp_mysql_int8,\n \"next_id\" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n PRIMARY KEY (\"next_id\")\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'int1', 'int2', 'int3', 'int4 unsigned', 'int8', 'int4' ), + array_column( $metadata[0]['columns'], 'type' ) + ); + $this->assertSame( 'auto_increment', $metadata[0]['columns'][5]['extra'] ); + } + + /** + * 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\" __wp_mysql_longtext NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_keyword_identifiers__name" ON "wp_keyword_identifiers" (SUBSTR(CAST("name" AS text), 1, 191))', + 'CREATE INDEX "wp_keyword_identifiers__description" ON "wp_keyword_identifiers" (SUBSTR(CAST("description" AS text), 1, 191))', + ), + $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 become PostgreSQL expression indexes. + */ + public function test_translate_prefix_index_lengths_as_expression_indexes(): 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" (SUBSTR(CAST("meta_key" AS text), 1, 191))', + ), + $this->translate( + 'CREATE TABLE wp_postmeta ( + meta_id bigint(20) unsigned NOT NULL auto_increment, + meta_key varchar(255) default NULL, + PRIMARY KEY (meta_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests unique MySQL prefix indexes become PostgreSQL expression indexes. + */ + public function test_translate_unique_prefix_index_lengths_as_expression_indexes(): void { + $sql = 'CREATE TABLE wp_prefix_unique ( + id bigint(20) unsigned NOT NULL auto_increment, + slug varchar(255) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_prefix_unique\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"slug\" varchar(255) NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE UNIQUE INDEX "wp_prefix_unique__slug_prefix" ON "wp_prefix_unique" (SUBSTR(CAST("slug" AS text), 1, 10))', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( '10', (string) $metadata[0]['indexes'][1]['columns'][0]['sub_part'] ); + } + + /** + * Tests MySQL key part directions are preserved in secondary index DDL and metadata. + */ + public function test_translate_preserves_secondary_index_directions(): void { + $sql = 'CREATE TABLE wp_directional_indexes ( + id bigint(20) unsigned NOT NULL auto_increment, + score int NOT NULL, + name varchar(255) NOT NULL, + created_at datetime NOT NULL, + PRIMARY KEY (id), + KEY score_name (score ASC, name(16) DESC, created_at DESC) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_directional_indexes\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"score\" integer NOT NULL,\n \"name\" varchar(255) NOT NULL,\n \"created_at\" __wp_mysql_datetime NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_directional_indexes__score_name" ON "wp_directional_indexes" ("score" ASC, SUBSTR(CAST("name" AS text), 1, 16) DESC, "created_at" DESC)', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'A', 'D', 'D' ), + array_column( $metadata[0]['indexes'][1]['columns'], 'collation' ) + ); + $this->assertSame( + array( null, 16, null ), + array_column( $metadata[0]['indexes'][1]['columns'], 'sub_part' ) + ); + } + + /** + * Tests supported MySQL BTREE index options are ignored for PostgreSQL DDL. + */ + public function test_translate_ignores_supported_btree_index_options(): void { + $sql = 'CREATE TABLE wp_index_options ( + id int NOT NULL, + value varchar(255) NOT NULL, + KEY value_lookup USING BTREE (value) KEY_BLOCK_SIZE=8 INVISIBLE COMMENT "Lookup", + UNIQUE KEY id_lookup (id) VISIBLE + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_index_options\" (\n \"id\" integer NOT NULL,\n \"value\" varchar(255) NOT NULL\n)", + 'CREATE INDEX "wp_index_options__value_lookup" ON "wp_index_options" (SUBSTR(CAST("value" AS text), 1, 191))', + 'CREATE UNIQUE INDEX "wp_index_options__id_lookup" ON "wp_index_options" ("id")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'value_lookup', 'id_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + } + + /** + * Tests inline column constraints are translated and preserved in metadata. + */ + public function test_translate_inline_column_constraints_and_metadata(): void { + $sql = 'CREATE TABLE wp_inline_constraints ( + id bigint(20) unsigned NOT NULL auto_increment PRIMARY KEY, + slug varchar(100) UNIQUE, + parent_id bigint(20) unsigned REFERENCES wp_inline_parent(id) ON DELETE CASCADE ON UPDATE SET NULL + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_constraints\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,\n \"slug\" varchar(100) CONSTRAINT \"slug\" UNIQUE,\n \"parent_id\" __wp_mysql_bigint_20_unsigned CONSTRAINT \"wp_inline_constraints_ibfk_1\" REFERENCES \"wp_inline_parent\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'PRIMARY', 'slug' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( + array( + 'name' => 'wp_inline_constraints_ibfk_1', + 'columns' => array( 'parent_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_inline_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + ), + $metadata[0]['foreign_keys'] + ); + $this->assertSame( 'NO', $metadata[0]['columns'][0]['nullable'] ); + } + + /** + * Tests table-level foreign keys are translated and preserved in metadata. + */ + public function test_translate_table_level_foreign_keys_and_metadata(): void { + $sql = 'CREATE TABLE wp_table_foreign_keys ( + id int NOT NULL, + parent_id int NOT NULL, + parent_site_id int NOT NULL, + parent_extra_id int NOT NULL, + CONSTRAINT fk_parent FOREIGN KEY parent_lookup (parent_id, parent_site_id) REFERENCES wp_parent (id, site_id) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT wp_table_foreign_keys_ibfk_1 FOREIGN KEY (parent_extra_id) REFERENCES wp_parent (id), + FOREIGN KEY (parent_id) REFERENCES wp_parent (id) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_table_foreign_keys\" (\n \"id\" integer NOT NULL,\n \"parent_id\" integer NOT NULL,\n \"parent_site_id\" integer NOT NULL,\n \"parent_extra_id\" integer NOT NULL,\n CONSTRAINT \"fk_parent\" FOREIGN KEY (\"parent_id\", \"parent_site_id\") REFERENCES \"wp_parent\" (\"id\", \"site_id\") ON DELETE CASCADE ON UPDATE RESTRICT,\n CONSTRAINT \"wp_table_foreign_keys_ibfk_1\" FOREIGN KEY (\"parent_extra_id\") REFERENCES \"wp_parent\" (\"id\"),\n CONSTRAINT \"wp_table_foreign_keys_ibfk_2\" FOREIGN KEY (\"parent_id\") REFERENCES \"wp_parent\" (\"id\")\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( + array( + 'name' => 'fk_parent', + 'columns' => array( 'parent_id', 'parent_site_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id', 'site_id' ), + 'update_rule' => 'RESTRICT', + 'delete_rule' => 'CASCADE', + ), + array( + 'name' => 'wp_table_foreign_keys_ibfk_1', + 'columns' => array( 'parent_extra_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + array( + 'name' => 'wp_table_foreign_keys_ibfk_2', + 'columns' => array( 'parent_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + ), + $metadata[0]['foreign_keys'] + ); + } + + /** + * Tests unsupported MySQL index options fail explicitly. + */ + public function test_translate_rejects_unsupported_index_options(): void { + $queries = array( + 'CREATE TABLE wp_bad_index_option (id int, body text, FULLTEXT KEY body_fulltext (body) WITH PARSER ngram)', + 'CREATE TABLE wp_bad_index_option (id int, shape point, SPATIAL KEY shape_spatial (shape) KEY_BLOCK_SIZE=8)', + 'CREATE TABLE wp_bad_index_option (id int, PRIMARY KEY (id) INVISIBLE)', + ); + + foreach ( $queries as $query ) { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + try { + $translator->translate_schema( $query ); + $this->fail( 'Expected unsupported CREATE TABLE index option to throw.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported CREATE TABLE index option.', $exception->getMessage(), $query ); + } + } + } + + /** + * Tests HASH index declarations are accepted as MySQL-compatible BTREE metadata. + */ + public function test_translate_accepts_hash_indexes_as_btree(): void { + $sql = 'CREATE TABLE wp_hash_index ( + id int, + value varchar(255), + slug varchar(191), + KEY value_lookup USING HASH (value), + UNIQUE KEY slug_lookup (slug) USING HASH + )'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_hash_index\" (\n \"id\" integer,\n \"value\" varchar(255),\n \"slug\" varchar(191)\n)", + 'CREATE INDEX "wp_hash_index__value_lookup" ON "wp_hash_index" (SUBSTR(CAST("value" AS text), 1, 191))', + 'CREATE UNIQUE INDEX "wp_hash_index__slug_lookup" ON "wp_hash_index" ("slug")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'value_lookup', 'slug_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( 'BTREE', 'BTREE' ), + array_column( $metadata[0]['indexes'], 'index_type' ) + ); + } + + /** + * Tests unsupported MySQL column attributes fail explicitly. + */ + public function test_translate_rejects_unsupported_column_attributes(): void { + $queries = array( + 'CREATE TABLE wp_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) STORED)', + 'CREATE TABLE wp_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) VIRTUAL)', + 'CREATE TABLE wp_bad_column_attribute (id int INVISIBLE)', + 'CREATE TABLE wp_bad_column_attribute (id int VISIBLE)', + 'CREATE TABLE wp_bad_column_attribute (id int COLUMN_FORMAT FIXED)', + 'CREATE TABLE wp_bad_column_attribute (id int STORAGE DISK)', + ); + + foreach ( $queries as $query ) { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + try { + $translator->translate_schema( $query ); + $this->fail( 'Expected unsupported CREATE TABLE column attribute to throw.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported CREATE TABLE column attribute.', $exception->getMessage(), $query ); + } + } + } + + /** + * Tests zero date defaults are translated as text while MySQL metadata is preserved. + */ + public function test_translate_zero_date_defaults_as_text_and_metadata_defaults(): void { + $sql = "CREATE TABLE wp_zero_dates ( + id bigint(20) unsigned NOT NULL auto_increment, + created_date date NOT NULL DEFAULT '0000-00-00', + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4"; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_zero_dates\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"created_date\" __wp_mysql_date NOT NULL DEFAULT '0000-00-00',\n \"created_at\" __wp_mysql_datetime NOT NULL DEFAULT '0000-00-00 00:00:00',\n \"updated_at\" __wp_mysql_timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',\n PRIMARY KEY (\"id\")\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'date', 'datetime', 'timestamp' ), + array_column( array_slice( $metadata[0]['columns'], 1 ), 'type' ) + ); + $this->assertSame( + array( '0000-00-00', '0000-00-00 00:00:00', '0000-00-00 00:00:00' ), + array_column( array_slice( $metadata[0]['columns'], 1 ), 'default' ) + ); + } + + /** + * Tests FULLTEXT and SPATIAL indexes are metadata-only in PostgreSQL DDL. + */ + public function test_translate_fulltext_and_spatial_indexes_as_metadata_only(): void { + $sql = 'CREATE TABLE wp_search_geo ( + id bigint(20) unsigned NOT NULL, + body longtext NOT NULL, + shape point NOT NULL, + PRIMARY KEY (id), + FULLTEXT KEY body_fulltext (body), + SPATIAL KEY shape_spatial (shape), + KEY id_lookup (id) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_search_geo\" (\n \"id\" __wp_mysql_bigint_20_unsigned NOT NULL,\n \"body\" __wp_mysql_longtext NOT NULL,\n \"shape\" __wp_mysql_point NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_search_geo__id_lookup" ON "wp_search_geo" ("id")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'PRIMARY', 'body_fulltext', 'shape_spatial', 'id_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( 'BTREE', 'FULLTEXT', 'SPATIAL', 'BTREE' ), + array_column( $metadata[0]['indexes'], 'index_type' ) + ); + $this->assertNull( $metadata[0]['indexes'][1]['columns'][0]['sub_part'] ); + $this->assertSame( 32, $metadata[0]['indexes'][2]['columns'][0]['sub_part'] ); + } + + /** + * Tests temporary IF NOT EXISTS statements. + */ + public function test_translate_temporary_if_not_exists(): void { + $this->assertSame( + array( + "CREATE TEMPORARY TABLE IF NOT EXISTS \"wp_tmp\" (\n \"id\" __wp_mysql_int_11 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', + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + 'comment' => '', + 'columns' => array( + array( + 'name' => 'a', + 'type' => 'varchar(50)', + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + 'comment' => '', + 'ordinal' => 1, + ), + array( + 'name' => 'b', + 'type' => 'text', + 'charset' => 'koi8r', + 'collation' => 'koi8r_general_ci', + 'comment' => '', + 'ordinal' => 2, + ), + array( + 'name' => 'c', + 'type' => 'binary(1)', + 'charset' => null, + 'collation' => null, + 'comment' => '', + 'ordinal' => 3, + ), + array( + 'name' => 'd', + 'type' => 'int', + 'charset' => null, + 'collation' => null, + 'comment' => '', + 'ordinal' => 4, + ), + ), + ), + ), + $translator->extract_schema_metadata( + 'CREATE TABLE wp_charset_test ( + a VARCHAR(50) CHARACTER SET latin1, + b TEXT COLLATE koi8r_general_ci, + c BINARY, + d INT + ) DEFAULT CHARSET utf8mb3' + ) + ); + } + + /** + * Tests MySQL table, column, and index comments are extracted from CREATE TABLE statements. + */ + public function test_extract_schema_metadata_preserves_mysql_comments(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( + "CREATE TABLE wp_comment_meta ( + id int NOT NULL COMMENT 'ID comment', + value varchar(50) COMMENT \"Value comment\", + KEY value_lookup (value) COMMENT 'Index comment' + ) COMMENT='Table comment'", + true + ); + + $this->assertSame( 'Table comment', $metadata[0]['comment'] ); + $this->assertSame( array( 'ID comment', 'Value comment' ), array_column( $metadata[0]['columns'], 'comment' ) ); + $this->assertSame( 'Index comment', $metadata[0]['indexes'][0]['comment'] ); + } + + /** + * Tests numeric precision and scale are preserved in DDL and metadata. + */ + public function test_numeric_precision_and_scale_are_preserved(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_numeric_test ( + amount DECIMAL(10,2) NOT NULL, + ratio NUMERIC(12,6), + score FLOAT(10,3), + measure DOUBLE(8,4) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_numeric_test\" (\n \"amount\" numeric(10,2) NOT NULL,\n \"ratio\" __wp_mysql_numeric_12_6,\n \"score\" __wp_mysql_float_10_3,\n \"measure\" __wp_mysql_double_8_4\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql ); + + $this->assertSame( 'decimal(10,2)', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'numeric(12,6)', $metadata[0]['columns'][1]['type'] ); + $this->assertSame( 'float(10,3)', $metadata[0]['columns'][2]['type'] ); + $this->assertSame( 'double(8,4)', $metadata[0]['columns'][3]['type'] ); + } + + /** + * Tests MySQL data type aliases translate while preserving MySQL metadata. + */ + public function test_mysql_data_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_alias_test ( + flags BIT(10), + enabled BOOL NOT NULL DEFAULT 0, + toggled BOOLEAN, + amount DEC(10,2), + fixed_value FIXED(8,3), + real_value REAL + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_alias_test\" (\n \"flags\" __wp_mysql_bit_10,\n \"enabled\" __wp_mysql_bool NOT NULL DEFAULT '0',\n \"toggled\" __wp_mysql_boolean,\n \"amount\" __wp_mysql_dec_10_2,\n \"fixed_value\" __wp_mysql_fixed_8_3,\n \"real_value\" __wp_mysql_real\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql ); + + $this->assertSame( 'bit(10)', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'bool', $metadata[0]['columns'][1]['type'] ); + $this->assertSame( 'boolean', $metadata[0]['columns'][2]['type'] ); + $this->assertSame( 'dec(10,2)', $metadata[0]['columns'][3]['type'] ); + $this->assertSame( 'fixed(8,3)', $metadata[0]['columns'][4]['type'] ); + $this->assertSame( 'real', $metadata[0]['columns'][5]['type'] ); + } + + /** + * Tests MySQL character type aliases translate while preserving MySQL metadata. + */ + public function test_mysql_character_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_character_alias_test ( + c1 CHAR, + c2 CHARACTER(10), + c3 CHAR VARYING(255), + c4 CHARACTER VARYING(255), + c5 NATIONAL CHAR, + c6 NCHAR, + c7 NATIONAL CHAR(10), + c8 NCHAR(10), + c9 NCHAR VARCHAR(255), + c10 NCHAR VARYING(255), + c11 NVARCHAR(255), + c12 NATIONAL VARCHAR(255), + c13 NATIONAL CHAR VARYING(255), + c14 NATIONAL CHARACTER VARYING(255) + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_character_alias_test\" (\n \"c1\" char(1),\n \"c2\" char(10),\n \"c3\" varchar(255),\n \"c4\" varchar(255),\n \"c5\" char(1),\n \"c6\" char(1),\n \"c7\" char(10),\n \"c8\" char(10),\n \"c9\" varchar(255),\n \"c10\" varchar(255),\n \"c11\" varchar(255),\n \"c12\" varchar(255),\n \"c13\" varchar(255),\n \"c14\" varchar(255)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( + 'char(1)', + 'char(10)', + 'varchar(255)', + 'varchar(255)', + 'char(1)', + 'char(1)', + 'char(10)', + 'char(10)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + ), + array_column( $metadata[0]['columns'], 'type' ) + ); + $this->assertSame( + array( 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8' ), + array_column( $metadata[0]['columns'], 'charset' ) + ); + $this->assertSame( + array( 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci' ), + array_column( $metadata[0]['columns'], 'collation' ) + ); + } + + /** + * Tests MySQL LONG-prefixed aliases translate while preserving MySQL metadata. + */ + public function test_mysql_long_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_long_alias_test ( + c1 LONG VARCHAR, + c2 LONG CHAR, + c3 LONG CHAR VARYING, + c4 LONG CHARACTER, + c5 LONG CHARACTER VARYING, + c6 LONG VARBINARY, + c7 LONG, + c8 LONG BYTE + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_long_alias_test\" (\n \"c1\" __wp_mysql_mediumtext,\n \"c2\" __wp_mysql_mediumtext,\n \"c3\" __wp_mysql_mediumtext,\n \"c4\" __wp_mysql_mediumtext,\n \"c5\" __wp_mysql_mediumtext,\n \"c6\" __wp_mysql_mediumblob,\n \"c7\" __wp_mysql_mediumtext,\n \"c8\" __wp_mysql_mediumblob\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'mediumtext', 'mediumtext', 'mediumtext', 'mediumtext', 'mediumtext', 'mediumblob', 'mediumtext', 'mediumblob' ), + array_column( $metadata[0]['columns'], 'type' ) + ); + $this->assertSame( + array( 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', null, 'utf8mb4', null ), + array_column( $metadata[0]['columns'], 'charset' ) + ); + $this->assertSame( + array( 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', null, 'utf8mb4_unicode_ci', null ), + array_column( $metadata[0]['columns'], 'collation' ) + ); + } + + /** + * Tests MySQL SERIAL translates to identity DDL and preserves MySQL metadata. + */ + public function test_serial_type_is_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_serial_test ( + id SERIAL, + label VARCHAR(20) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_serial_test\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL CONSTRAINT \"id\" UNIQUE,\n \"label\" varchar(20)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( 'bigint unsigned', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'NO', $metadata[0]['columns'][0]['nullable'] ); + $this->assertSame( 'auto_increment', $metadata[0]['columns'][0]['extra'] ); + $this->assertSame( 'id', $metadata[0]['indexes'][0]['name'] ); + $this->assertSame( '0', $metadata[0]['indexes'][0]['non_unique'] ); + $this->assertSame( 'id', $metadata[0]['indexes'][0]['columns'][0]['column_name'] ); + } + + /** + * Tests MySQL enumerated string types translate while preserving metadata. + */ + public function test_enum_and_set_types_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = "CREATE TABLE wp_enum_set_test ( + status ENUM('draft','published') NOT NULL DEFAULT 'draft', + flags SET('featured','archived') DEFAULT 'featured' + ) DEFAULT CHARACTER SET utf8mb4"; + + $this->assertSame( + array( + "CREATE TABLE \"wp_enum_set_test\" (\n \"status\" __wp_mysql_enum_02ccf983d79439de NOT NULL DEFAULT 'draft',\n \"flags\" __wp_mysql_set_3c255ba5f59d3194 DEFAULT 'featured'\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( "enum('draft','published')", $metadata[0]['columns'][0]['type'] ); + $this->assertSame( "set('featured','archived')", $metadata[0]['columns'][1]['type'] ); + $this->assertSame( array( 'utf8mb4', 'utf8mb4' ), array_column( $metadata[0]['columns'], 'charset' ) ); + $this->assertSame( array( 'draft', 'featured' ), array_column( $metadata[0]['columns'], 'default' ) ); + } + + /** + * Tests MySQL JSON translates to text storage while preserving MySQL metadata. + */ + public function test_json_type_is_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_json_test ( + id int NOT NULL, + payload JSON DEFAULT NULL + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_json_test\" (\n \"id\" integer NOT NULL,\n \"payload\" __wp_mysql_json DEFAULT NULL\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( 'json', $metadata[0]['columns'][1]['type'] ); + $this->assertNull( $metadata[0]['columns'][1]['charset'] ); + $this->assertNull( $metadata[0]['columns'][1]['collation'] ); + $this->assertSame( 'YES', $metadata[0]['columns'][1]['nullable'] ); + $this->assertNull( $metadata[0]['columns'][1]['default'] ); + } + + /** + * Tests unsupported CREATE TABLE ... SELECT statements are rejected. + */ + public function test_translate_rejects_create_table_as_select(): void { + $this->expectException( InvalidArgumentException::class ); + $this->translate( 'CREATE TABLE wp_copy AS SELECT 1 AS id' ); + } + + /** + * Tests inline and table CHECK constraints are translated. + */ + public function test_translate_supports_check_constraints(): void { + $sql = 'CREATE TABLE wp_inline_check ( + id int CHECK (id > 0), + `score` int NOT NULL CHECK (`score` < 10), + CONSTRAINT c CHECK (id < 100), + CHECK (score >= 0) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_check\" (\n \"id\" integer CONSTRAINT \"wp_inline_check_chk_1\" CHECK (id > 0),\n \"score\" integer NOT NULL CONSTRAINT \"wp_inline_check_chk_2\" CHECK (\"score\" < 10),\n CONSTRAINT \"c\" CHECK (id < 100),\n CONSTRAINT \"wp_inline_check_chk_3\" CHECK (score >= 0)\n)", + ), + $this->translate( $sql ) + ); + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( array( 'id', 'score' ), array_column( $metadata[0]['columns'], 'name' ) ); + $this->assertSame( array(), $metadata[0]['indexes'] ); + $this->assertSame( array(), $metadata[0]['foreign_keys'] ); + $this->assertSame( + array( + array( + 'name' => 'wp_inline_check_chk_1', + 'check_clause' => 'id > 0', + 'enforced' => 'YES', + ), + array( + 'name' => 'wp_inline_check_chk_2', + 'check_clause' => '"score" < 10', + 'enforced' => 'YES', + ), + array( + 'name' => 'c', + 'check_clause' => 'id < 100', + 'enforced' => 'YES', + ), + array( + 'name' => 'wp_inline_check_chk_3', + 'check_clause' => 'score >= 0', + 'enforced' => 'YES', + ), + ), + $metadata[0]['checks'] + ); + } + + /** + * Tests CHECK metadata preserves PostgreSQL-only expression rewrites. + */ + public function test_translate_check_metadata_tracks_postgresql_expression_when_it_differs(): void { + $sql = 'CREATE TABLE wp_json_check (data JSON CHECK (json_valid(data)))'; + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( + array( + 'name' => 'wp_json_check_chk_1', + 'check_clause' => 'json_valid(data)', + 'enforced' => 'YES', + 'postgresql_check_clause' => '(CASE WHEN data IS NULL THEN NULL ELSE (CAST(data AS jsonb) IS NOT NULL) END)', + ), + ), + $metadata[0]['checks'] + ); + } + + /** + * Tests NOT ENFORCED CHECK constraints use PostgreSQL placeholders. + */ + public function test_translate_not_enforced_check_constraints_as_postgresql_placeholders(): void { + $sql = 'CREATE TABLE wp_inline_check ( + id int CHECK (id > 0) NOT ENFORCED, + score int CHECK (score > 0) ENFORCED, + CONSTRAINT c CHECK (id < 100) NOT ENFORCED + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_check\" (\n \"id\" integer CONSTRAINT \"wp_inline_check_chk_1\" CHECK (true),\n \"score\" integer CONSTRAINT \"wp_inline_check_chk_2\" CHECK (score > 0),\n CONSTRAINT \"c\" CHECK (true)\n)", + ), + $this->translate( $sql ) + ); + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( array( 'YES', 'YES' ), array_column( $metadata[0]['columns'], 'nullable' ) ); + $this->assertSame( + array( + array( + 'name' => 'wp_inline_check_chk_1', + 'check_clause' => 'id > 0', + 'enforced' => 'NO', + ), + array( + 'name' => 'wp_inline_check_chk_2', + 'check_clause' => 'score > 0', + 'enforced' => 'YES', + ), + array( + 'name' => 'c', + 'check_clause' => 'id < 100', + 'enforced' => 'NO', + ), + ), + $metadata[0]['checks'] + ); + } + + /** + * Translates MySQL CREATE TABLE SQL. + * + * @param string $sql MySQL CREATE TABLE statement. + * @return string[] PostgreSQL DDL statements. + */ + private function translate( string $sql ): array { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + return $translator->translate_schema( $sql ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php new file mode 100644 index 000000000..bd8972260 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -0,0 +1,4247 @@ +run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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-postgresql.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$capabilities = array(); +foreach ( + array( + 'collation', + 'group_concat', + 'subqueries', + 'identifier_placeholders', + 'utf8mb4', + 'utf8mb4_520', + 'COLLATION', + 'GROUP_CONCAT', + 'SUBQUERIES', + 'IDENTIFIER_PLACEHOLDERS', + 'UTF8MB4', + 'UTF8MB4_520', + 'set_charset', + 'SET_CHARSET', + 'unsupported_postgresql_capability', + ) as $capability +) { + $capabilities[ $capability ] = $db->has_cap( $capability ); +} + +wp_postgresql_db_test_respond( + array( + 'db_version' => $db->db_version(), + 'capabilities' => $capabilities, + ) +); +PHP + ); + + $this->assertSame( '8.0', $result['db_version'] ); + $this->assertSame( + array( + 'collation' => true, + 'group_concat' => true, + 'subqueries' => true, + 'identifier_placeholders' => true, + 'utf8mb4' => true, + 'utf8mb4_520' => true, + 'COLLATION' => true, + 'GROUP_CONCAT' => true, + 'SUBQUERIES' => true, + 'IDENTIFIER_PLACEHOLDERS' => true, + 'UTF8MB4' => true, + 'UTF8MB4_520' => true, + 'set_charset' => true, + 'SET_CHARSET' => true, + 'unsupported_postgresql_capability' => false, + ), + $result['capabilities'] + ); + } + + /** + * Tests the wpdb adapter applies set_charset() to the PostgreSQL driver state. + */ + public function test_set_charset_updates_postgresql_driver_session_state(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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' => wp_postgresql_tests_create_pgsql_pdo() ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->set_charset( $driver, 'utf8', 'utf8_general_ci' ); + +$collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); +$charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + +wp_postgresql_db_test_respond( + array( + 'charset' => $charset[0]->Value, + 'collation' => $collation[0]->Value, + ) +); +PHP + ); + + $this->assertSame( + array( + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + ), + $result + ); + } + + /** + * Tests set_charset() uses wpdb defaults and ignores invalid handles. + */ + public function test_set_charset_uses_defaults_and_ignores_invalid_handles(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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' => wp_postgresql_tests_create_pgsql_pdo() ) ), + 'wptests' +); + +function wp_postgresql_db_charset_state( WP_PostgreSQL_Driver $driver ) { + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + + return array( + 'charset' => $charset[0]->Value, + 'collation' => $collation[0]->Value, + ); +} + +$initial = wp_postgresql_db_charset_state( $driver ); + +$db->charset = 'latin1'; +$db->collate = ''; +$db->set_charset( $driver ); +$after_defaults = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( $driver, '', 'utf8_general_ci' ); +$after_empty_charset = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( new stdClass(), 'utf8mb4', 'utf8mb4_bin' ); +$after_non_driver = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( $driver, 'utf8mb4', '' ); +$after_empty_collate = wp_postgresql_db_charset_state( $driver ); + +wp_postgresql_db_test_respond( + array( + 'initial' => $initial, + 'after_defaults' => $after_defaults, + 'after_empty_charset' => $after_empty_charset, + 'after_non_driver' => $after_non_driver, + 'after_empty_collate' => $after_empty_collate, + ) +); +PHP + ); + + $this->assertSame( + array( + 'initial' => array( + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ), + 'after_defaults' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_empty_charset' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_non_driver' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_empty_collate' => array( + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ), + ), + $result + ); + } + + /** + * Tests the wpdb adapter applies WordPress charset upgrade rules. + */ + public function test_determine_charset_applies_wordpress_utf8mb4_upgrade_rules(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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 { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +$GLOBALS['wp_postgresql_db_test_filter_calls'] = array(); + +function apply_filters( $hook_name, $value ) { + $GLOBALS['wp_postgresql_db_test_filter_calls'][] = array( + 'hook_name' => $hook_name, + 'value' => $value, + ); + + return $value; +} + +class wpdb { + protected $incompatible_modes = array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => wp_postgresql_tests_create_pgsql_pdo() ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$initial_mode = $driver->get_sql_mode(); +$db->set_sql_mode(); +$mode_after_empty_call = $driver->get_sql_mode(); +$filter_calls_after_empty = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$zero_date_insert_result = null; +$zero_date_value = null; +try { + $driver->query( + 'CREATE TABLE wptests_zero_dates ( + id bigint(20) NOT NULL, + logged_at datetime NOT NULL, + PRIMARY KEY (id) + )' + ); + $zero_date_insert_result = $driver->query( + "INSERT INTO `wptests_zero_dates` (`id`, `logged_at`) VALUES (1, '0000-00-00 00:00:00')" + ); + $zero_date_rows = $driver->query( 'SELECT logged_at FROM wptests_zero_dates WHERE id = 1' ); + $zero_date_value = $zero_date_rows[0]->logged_at ?? null; +} catch ( Throwable $e ) { + $zero_date_insert_result = get_class( $e ) . ': ' . $e->getMessage(); +} + +$db->set_sql_mode( + array( + 'strict_trans_tables', + 'NO_ZERO_DATE', + 'ansi_quotes', + 'no_engine_substitution', + ) +); +$mode_after_filtered_call = $driver->get_sql_mode(); +$filter_calls_after_modes = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$driver_property->setValue( $db, null ); +$db->set_sql_mode( array( 'STRICT_ALL_TABLES' ) ); +$mode_after_detached_call = $driver->get_sql_mode(); + +wp_postgresql_db_test_respond( + array( + 'initial_mode' => $initial_mode, + 'mode_after_empty_call' => $mode_after_empty_call, + 'filter_calls_after_empty' => $filter_calls_after_empty, + 'zero_date_insert_result' => $zero_date_insert_result, + 'zero_date_value' => $zero_date_value, + 'mode_after_filtered_call' => $mode_after_filtered_call, + 'filter_calls_after_modes' => $filter_calls_after_modes, + 'mode_after_detached_call' => $mode_after_detached_call, + ) +); +PHP + ); + + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $result['initial_mode'] + ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_IN_DATE', + $result['mode_after_empty_call'] + ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + ), + $result['filter_calls_after_empty'] + ); + $this->assertSame( 1, $result['zero_date_insert_result'] ); + $this->assertSame( '0000-00-00 00:00:00', $result['zero_date_value'] ); + $this->assertSame( 'ANSI_QUOTES,NO_ENGINE_SUBSTITUTION', $result['mode_after_filtered_call'] ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + ), + $result['filter_calls_after_modes'] + ); + $this->assertSame( 'ANSI_QUOTES,NO_ENGINE_SUBSTITUTION', $result['mode_after_detached_call'] ); + } + + /** + * Tests suppressed print_error() calls record explicit and stored errors. + */ + public function test_print_error_records_explicit_and_stored_errors_when_suppressed(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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-postgresql.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-postgresql.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-postgresql.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-postgresql.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 charset and length metadata through real PostgreSQL catalog state. + */ + public function test_postgresql_metadata_uses_real_driver_and_native_catalog_paths(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +function __( $text ) { + return $text; +} + +class WP_Error { + public $code; + public $message; + + public function __construct( $code = '', $message = '' ) { + $this->code = $code; + $this->message = $message; + } +} + +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; + public $check_current_query = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$pdo = wp_postgresql_tests_create_pgsql_pdo(); +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + 'wptests' +); +$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 ); + +$pdo->exec( 'SET search_path TO public' ); +$declared_table = 'wptests_declared_metadata_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +$native_table = 'wptests_native_metadata_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +register_shutdown_function( + static function () use ( $pdo, $declared_table, $native_table ): void { + foreach ( array( $declared_table, $native_table ) as $table ) { + try { + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $table ) . ' CASCADE' ); + } catch ( Throwable $e ) { + // Cleanup should not mask the isolated script result. + } + } + } +); + +$created_declared = $db->query( + 'CREATE TABLE `' . $declared_table . '` ( + a VARCHAR(50) CHARACTER SET utf8, + b LONGTEXT CHARACTER SET big5, + c INTEGER + ) DEFAULT CHARACTER SET utf8mb4' +); +$pdo->exec( 'CREATE TABLE ' . WP_PostgreSQL_Connection::quote_identifier_value( $native_table ) . ' (name varchar(75), body text)' ); + +wp_postgresql_db_test_respond( + array( + 'created_declared' => $created_declared, + 'declared_table' => $get_table_charset->invoke( $db, $declared_table ), + 'declared_a_charset' => $db->get_col_charset( $declared_table, 'a' ), + 'declared_b_charset' => $db->get_col_charset( strtoupper( $declared_table ), 'B' ), + 'declared_c_charset' => $db->get_col_charset( $declared_table, 'c' ), + 'declared_a_length' => $db->get_col_length( $declared_table, 'a' ), + 'declared_b_length' => $db->get_col_length( $declared_table, 'b' ), + 'native_table' => $get_table_charset->invoke( $db, $native_table ), + 'native_name_charset' => $db->get_col_charset( $native_table, 'name' ), + 'native_body_charset' => $db->get_col_charset( $native_table, 'body' ), + 'native_name_length' => $db->get_col_length( $native_table, 'name' ), + 'native_body_length' => $db->get_col_length( $native_table, 'body' ), + ) +); +PHP + ); + + $this->assertTrue( $result['created_declared'] ); + $this->assertSame( 'ascii', $result['declared_table'] ); + $this->assertSame( 'utf8', $result['declared_a_charset'] ); + $this->assertSame( 'big5', $result['declared_b_charset'] ); + $this->assertFalse( $result['declared_c_charset'] ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['declared_a_length'] + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 4294967295, + ), + $result['declared_b_length'] + ); + $this->assertSame( 'utf8mb4', $result['native_table'] ); + $this->assertSame( 'utf8mb4', $result['native_name_charset'] ); + $this->assertSame( 'utf8mb4', $result['native_body_charset'] ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 75, + ), + $result['native_name_length'] + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 65535, + ), + $result['native_body_length'] + ); + } + + /** + * Tests temporary table metadata uses the real active PostgreSQL temporary schema. + */ + public function test_postgresql_metadata_prefers_real_temporary_table_catalog(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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; + public $check_current_query = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$pdo = wp_postgresql_tests_create_pgsql_pdo(); +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + 'wptests' +); +$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 ); + +$pdo->exec( 'SET search_path TO public' ); +$shadow_table = 'wptests_shadow_charset_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +$unrelated_table = 'wptests_unrelated_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +register_shutdown_function( + static function () use ( $pdo, $shadow_table, $unrelated_table ): void { + foreach ( array( $shadow_table, $unrelated_table ) as $table ) { + try { + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $table ) . ' CASCADE' ); + } catch ( Throwable $e ) { + // Cleanup should not mask the isolated script result. + } + } + } +); + +$permanent_created = $db->query( + 'CREATE TABLE `' . $shadow_table . '` ( + temp_value VARCHAR(50) CHARACTER SET latin1 + )' +); +$temporary_created = $db->query( + 'CREATE TEMPORARY TABLE `' . $shadow_table . '` ( + temp_value VARCHAR(50) CHARACTER SET big5 + )' +); + +$temporary_charset_before_alter = $db->get_col_charset( $shadow_table, 'temp_value' ); +$temporary_length_before_alter = $db->get_col_length( $shadow_table, 'temp_value' ); + +$unrelated_created = $db->query( 'CREATE TABLE `' . $unrelated_table . '` (id INTEGER NOT NULL)' ); +$unrelated_altered = $db->query( 'ALTER TABLE `' . $unrelated_table . '` ADD COLUMN flag INTEGER' ); + +$temporary_charset_after_alter = $db->get_col_charset( $shadow_table, 'temp_value' ); +$temporary_dropped = $db->query( 'DROP TEMPORARY TABLE `' . $shadow_table . '`' ); +$permanent_charset_after_drop = $db->get_col_charset( $shadow_table, 'temp_value' ); + +wp_postgresql_db_test_respond( + array( + 'permanent_created' => $permanent_created, + 'temporary_created' => $temporary_created, + 'temporary_charset_before_alter' => $temporary_charset_before_alter, + 'temporary_length_before_alter' => $temporary_length_before_alter, + 'unrelated_created' => $unrelated_created, + 'unrelated_altered' => $unrelated_altered, + 'temporary_charset_after_alter' => $temporary_charset_after_alter, + 'temporary_dropped' => $temporary_dropped, + 'permanent_charset_after_drop' => $permanent_charset_after_drop, + ) +); +PHP + ); + + $this->assertTrue( $result['permanent_created'] ); + $this->assertTrue( $result['temporary_created'] ); + $this->assertSame( 'big5', $result['temporary_charset_before_alter'] ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['temporary_length_before_alter'] + ); + $this->assertTrue( $result['unrelated_created'] ); + $this->assertTrue( $result['unrelated_altered'] ); + $this->assertSame( 'big5', $result['temporary_charset_after_alter'] ); + $this->assertTrue( $result['temporary_dropped'] ); + $this->assertSame( 'latin1', $result['permanent_charset_after_drop'] ); + } + + /** + * Tests metadata caches reload from real PostgreSQL catalog data after clearing. + */ + public function test_postgresql_metadata_cache_reloads_from_real_catalog_after_clear(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +function __( $text ) { + return $text; +} + +class WP_Error { + public $code; + public $message; + + public function __construct( $code = '', $message = '' ) { + $this->code = $code; + $this->message = $message; + } +} + +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'; + +$pdo = wp_postgresql_tests_create_pgsql_pdo(); +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + 'wptests' +); +$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 ); + +$pdo->exec( 'CREATE TABLE wptests_cache_probe (name varchar(50))' ); + +$first = $db->get_col_length( 'wptests_cache_probe', 'name' ); + +$pdo->exec( 'ALTER TABLE wptests_cache_probe ALTER COLUMN name TYPE varchar(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, + ) +); +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'] + ); + } + + /** + * Tests CREATE and DROP invalidate metadata caches using real PostgreSQL tables. + */ + public function test_postgresql_metadata_cache_invalidates_after_real_create_and_drop(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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; + public $check_current_query = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$pdo = wp_postgresql_tests_create_pgsql_pdo(); +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + 'wptests' +); +$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 ); + +$pdo->exec( 'SET search_path TO public' ); +$permanent_table = 'wptests_plain_metadata_cache_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +$temporary_table = 'wptests_temp_drop_metadata_cache_' . strtolower( bin2hex( random_bytes( 4 ) ) ); +register_shutdown_function( + static function () use ( $pdo, $permanent_table, $temporary_table ): void { + foreach ( array( $permanent_table, $temporary_table ) as $table ) { + try { + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $table ) . ' CASCADE' ); + } catch ( Throwable $e ) { + // Cleanup should not mask the isolated script result. + } + } + } +); + +$missing_permanent = $db->get_col_charset( $permanent_table, 'name' ); +$created_permanent = $db->query( + 'CREATE TABLE `' . $permanent_table . '` ( + id INTEGER NOT NULL, + name VARCHAR(191) NOT NULL + )' +); +$permanent_charset = $db->get_col_charset( $permanent_table, 'name' ); +$permanent_length = $db->get_col_length( $permanent_table, 'name' ); +$dropped_permanent = $db->query( 'DROP TABLE IF EXISTS `' . $permanent_table . '`' ); +$after_permanent = $db->get_col_charset( $permanent_table, 'name' ); + +$created_temporary = $db->query( + 'CREATE TEMPORARY TABLE `' . $temporary_table . '` ( + name VARCHAR(50) CHARACTER SET big5 + )' +); +$temporary_charset = $db->get_col_charset( $temporary_table, 'name' ); +$dropped_temporary = $db->query( 'DROP TEMPORARY TABLE `' . $temporary_table . '`' ); +$after_temporary = $db->get_col_charset( $temporary_table, 'name' ); + +wp_postgresql_db_test_respond( + array( + 'missing_permanent_error' => $missing_permanent instanceof WP_Error, + 'created_permanent' => $created_permanent, + 'permanent_charset' => $permanent_charset, + 'permanent_length' => $permanent_length, + 'dropped_permanent' => $dropped_permanent, + 'after_permanent_error' => $after_permanent instanceof WP_Error, + 'created_temporary' => $created_temporary, + 'temporary_charset' => $temporary_charset, + 'dropped_temporary' => $dropped_temporary, + 'after_temporary_error' => $after_temporary instanceof WP_Error, + ) +); +PHP + ); + + $this->assertTrue( $result['missing_permanent_error'] ); + $this->assertTrue( $result['created_permanent'] ); + $this->assertSame( 'utf8mb4', $result['permanent_charset'] ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 191, + ), + $result['permanent_length'] + ); + $this->assertTrue( $result['dropped_permanent'] ); + $this->assertTrue( $result['after_permanent_error'] ); + $this->assertTrue( $result['created_temporary'] ); + $this->assertSame( 'big5', $result['temporary_charset'] ); + $this->assertTrue( $result['dropped_temporary'] ); + $this->assertTrue( $result['after_temporary_error'] ); + } + + /** + * Tests metadata helpers return final table identifiers for qualified DDL. + */ + public function test_postgresql_metadata_helpers_parse_schema_qualified_table_names(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$create_table_name = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_postgresql_create_table_name' ); +$create_table_name->setAccessible( true ); + +$drop_table_names = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_postgresql_drop_table_names' ); +$drop_table_names->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'create_names' => array( + 'plain' => $create_table_name->invoke( $db, 'CREATE TABLE wptests_plain (id bigint)' ), + 'qualified' => $create_table_name->invoke( $db, 'CREATE TABLE app_schema.wptests_qualified (id bigint)' ), + 'quoted_qualified' => $create_table_name->invoke( $db, 'CREATE TABLE `app_schema`.`wptests_quoted` (id bigint)' ), + 'temporary_if_exists' => $create_table_name->invoke( $db, 'CREATE TEMPORARY TABLE IF NOT EXISTS `app_schema`.`wptests_temp` (id bigint)' ), + ), + 'drop_names' => array( + 'plain' => $drop_table_names->invoke( $db, 'DROP TABLE wptests_plain' ), + 'qualified_list' => $drop_table_names->invoke( $db, 'DROP TABLE IF EXISTS app_schema.wptests_one, `app_schema`.`wptests_two`, wptests_three CASCADE' ), + 'temporary_qualified' => $drop_table_names->invoke( $db, 'DROP TEMPORARY TABLE IF EXISTS `app_schema`.`wptests_temp`, scratch.wptests_other RESTRICT' ), + ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'plain' => 'wptests_plain', + 'qualified' => 'wptests_qualified', + 'quoted_qualified' => 'wptests_quoted', + 'temporary_if_exists' => 'wptests_temp', + ), + $result['create_names'] + ); + $this->assertSame( + array( + 'plain' => array( 'wptests_plain' ), + 'qualified_list' => array( 'wptests_one', 'wptests_two', 'wptests_three' ), + 'temporary_qualified' => array( 'wptests_temp', 'wptests_other' ), + ), + $result['drop_names'] + ); + } + + /** + * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. + */ + public function test_real_wpdb_prepare_identifier_placeholders_use_postgresql_quotes(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' + require_once getcwd() . '/bootstrap-postgresql.php'; + + function wp_load_translations_early() {} + function __( $text ) { + return $text; + } + function _doing_it_wrong() {} + function has_filter() { + return false; + } + function add_filter() { + return true; + } + + require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + + class WP_PostgreSQL_DB_Prepare_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + } + + class WP_PostgreSQL_DB_Prepare_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Prepare_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + } + + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + $driver_property->setAccessible( true ); + $driver_property->setValue( $db, new WP_PostgreSQL_DB_Prepare_Fake_Driver() ); + + $db->charset = 'utf8mb4'; + + $marker_like_value = '__wp_pg_identifier_' . spl_object_hash( $db ) . '_1_0__'; + $prepared_marker_collision = $db->prepare( + 'SELECT %s AS value FROM %i', + $marker_like_value, + 'my_table' + ); + + $identifier_collision_value = '__wp_pg_identifier_' . spl_object_hash( $db ) . '_4_1__'; + $prepared_identifier_collision = $db->prepare( + 'SELECT %i, %i', + $identifier_collision_value, + 'second' + ); + + $quote_identifier_nul_exception = null; + $quote_identifier_nul_message = null; + try { + $db->quote_identifier( "wp_\0posts" ); + } catch ( Throwable $e ) { + $quote_identifier_nul_exception = get_class( $e ); + $quote_identifier_nul_message = $e->getMessage(); + } + + wp_postgresql_db_test_respond( + array( + 'has_identifier_cap' => $db->has_cap( 'identifier_placeholders' ), + 'quoted_table' => $db->quote_identifier( 'wptests_options' ), + 'quoted_weird' => $db->quote_identifier( 'weird"name' ), + 'quote_identifier_nul_exception' => $quote_identifier_nul_exception, + 'quote_identifier_nul_message' => $quote_identifier_nul_message, + 'marker_like_value' => $marker_like_value, + 'prepared_marker_collision' => $prepared_marker_collision, + 'identifier_collision_value' => $identifier_collision_value, + 'prepared_identifier_collision' => $prepared_identifier_collision, + 'prepared_identifier' => $db->prepare( + 'SELECT * FROM %i WHERE %i = %s', + 'wptests_options', + 'option_name', + "Bob's" + ), + 'prepared_identifier_array' => $db->prepare( + 'SELECT %i FROM %i WHERE %i = %s', + array( 'option_value', 'wptests_options', 'option_name', "Bob's" ) + ), + 'prepared_formatted_identifier' => $db->prepare( + 'SELECT * FROM %05i WHERE %i = %s', + 'wptests_options', + 'option_name', + "Bob's" + ), + 'prepared_string' => $db->prepare( 'SELECT %s', "Bob's" ), + ) + ); +PHP + ); + + $this->assertTrue( $result['has_identifier_cap'] ); + $this->assertSame( '"wptests_options"', $result['quoted_table'] ); + $this->assertSame( '"weird""name"', $result['quoted_weird'] ); + $this->assertSame( 'InvalidArgumentException', $result['quote_identifier_nul_exception'] ); + $this->assertSame( + 'PostgreSQL identifiers cannot contain NUL bytes.', + $result['quote_identifier_nul_message'] + ); + $this->assertSame( + 'SELECT * FROM "wptests_options" WHERE "option_name" = \'Bob\\\'s\'', + $result['prepared_identifier'] + ); + $this->assertSame( + 'SELECT \'' . $result['marker_like_value'] . '\' AS value FROM "my_table"', + $result['prepared_marker_collision'] + ); + $this->assertSame( + 'SELECT "' . $result['identifier_collision_value'] . '", "second"', + $result['prepared_identifier_collision'] + ); + $this->assertNotSame( + 'SELECT ""second"", "second"', + $result['prepared_identifier_collision'] + ); + $this->assertSame( + 'SELECT "option_value" FROM "wptests_options" WHERE "option_name" = \'Bob\\\'s\'', + $result['prepared_identifier_array'] + ); + $this->assertSame( + 'SELECT * FROM `wptests_options` WHERE `option_name` = \'Bob\\\'s\'', + $result['prepared_formatted_identifier'] + ); + $this->assertSame( "SELECT 'Bob\\'s'", $result['prepared_string'] ); + } + + /** + * Tests db_connect() short-circuits when a PostgreSQL driver already exists. + */ + public function test_db_connect_short_circuits_when_postgresql_driver_already_exists(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = false; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = 'latin1'; + public $init_charset_calls = 0; + public $bail_calls = array(); + + public function init_charset() { + ++$this->init_charset_calls; + $this->charset = 'utf8mb4'; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Existing_Driver_Fake_Driver extends WP_PostgreSQL_Driver { + public function __construct() {} +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Existing_Driver_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$connect_result = $db->db_connect( false ); +$driver_after_connect = $driver_property->getValue( $db ); + +wp_postgresql_db_test_respond( + array( + 'connect_result' => $connect_result, + 'ready' => $db->ready, + 'is_mysql' => $db->is_mysql, + 'driver_same' => $driver_after_connect === $driver, + 'init_charset_calls' => $db->init_charset_calls, + 'bail_calls' => $db->bail_calls, + 'last_error' => $db->last_error, + 'charset' => $db->charset, + ) +); +PHP + ); + + $this->assertSame( + array( + 'connect_result' => true, + 'ready' => true, + 'is_mysql' => true, + 'driver_same' => true, + 'init_charset_calls' => 0, + 'bail_calls' => array(), + 'last_error' => 'previous error', + 'charset' => 'latin1', + ), + $result + ); + } + + /** + * Tests db_connect() with a reusable PostgreSQL PDO and connection lifecycle methods. + */ + public function test_db_connect_reuses_global_postgresql_pdo_and_exposes_connection_lifecycle(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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'; + +$pdo = wp_postgresql_tests_create_pgsql_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->assertIsString( $result['server_info'] ); + $this->assertNotSame( '', $result['server_info'] ); + unset( $result['server_info'] ); + + $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, + 'select_other_result' => false, + 'ready_after_other' => false, + 'select_current_result' => true, + 'ready_after_current' => true, + 'close_result' => true, + 'ready_after_close' => false, + 'driver_after_close' => true, + 'second_close_result' => false, + ), + $result, + 'The PostgreSQL wpdb adapter keeps is_mysql=true so WordPress runs charset and length validation paths.' + ); + } + + /** + * Tests close() clears stale ready state even without a driver handle. + */ + public function test_close_clears_stale_ready_state_without_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, null ); + +$db->ready = true; + +$close_result = $db->close(); +$ready_after_close = $db->ready; +$dbh_after_close = $driver_property->getValue( $db ); + +wp_postgresql_db_test_respond( + array( + 'close_result' => $close_result, + 'ready_after_close' => $ready_after_close, + 'dbh_after_close' => $dbh_after_close, + ) +); +PHP + ); + + $this->assertSame( + array( + 'close_result' => false, + 'ready_after_close' => false, + 'dbh_after_close' => null, + ), + $result + ); + } + + /** + * Tests PostgreSQL connection options normalize socket-style DB_HOST values. + */ + public function test_get_connection_options_normalizes_postgresql_socket_hosts(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $dbuser = ''; + public $dbpassword = ''; + public $dbname = ''; + public $dbhost = ''; + + public function parse_db_host( $host ) { + $socket = null; + $is_ipv6 = false; + + $socket_pos = strpos( $host, ':/' ); + if ( false !== $socket_pos ) { + $socket = substr( $host, $socket_pos + 1 ); + $host = substr( $host, 0, $socket_pos ); + } + + if ( substr_count( $host, ':' ) > 1 ) { + $pattern = '#^(?:\[)?(?P[0-9a-fA-F:]+)(?:\]:(?P[\d]+))?#'; + $is_ipv6 = true; + } else { + $pattern = '#^(?P[^:/]*)(?::(?P[\d]+))?#'; + } + + $matches = array(); + $result = preg_match( $pattern, $host, $matches ); + if ( 1 !== $result ) { + return false; + } + + $host = ! empty( $matches['host'] ) ? $matches['host'] : ''; + $port = ! empty( $matches['port'] ) ? abs( (int) $matches['port'] ) : null; + + return array( $host, $port, $socket, $is_ipv6 ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Unparsed_Host extends WP_PostgreSQL_DB { + public function __construct() {} + + public function parse_db_host( $host ) { + return false; + } +} + +function wp_postgresql_db_get_connection_options( WP_PostgreSQL_DB $db ) { + $method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_connection_options' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $db ); +} + +function wp_postgresql_db_options_for_host( $case, $dbhost ) { + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + $db->dbuser = 'user_' . $case; + $db->dbpassword = 'password_' . $case; + $db->dbname = 'name_' . $case; + $db->dbhost = $dbhost; + + return wp_postgresql_db_get_connection_options( $db ); +} + +$options = array( + 'host_only' => wp_postgresql_db_options_for_host( 'host_only', 'postgres' ), + 'host_port' => wp_postgresql_db_options_for_host( 'host_port', 'postgres:6543' ), + 'socket_file' => wp_postgresql_db_options_for_host( 'socket_file', 'localhost:/tmp/.s.PGSQL.6544' ), + 'explicit_port_socket_file' => wp_postgresql_db_options_for_host( 'explicit_port_socket_file', 'localhost:6545:/tmp/.s.PGSQL.6544' ), + 'socket_directory' => wp_postgresql_db_options_for_host( 'socket_directory', 'localhost:/var/run/postgresql' ), +); + +$unparsed_db = new WP_PostgreSQL_DB_Unparsed_Host(); +$unparsed_db->dbuser = 'user_unparsed_fallback'; +$unparsed_db->dbpassword = 'password_unparsed_fallback'; +$unparsed_db->dbname = 'name_unparsed_fallback'; +$unparsed_db->dbhost = 'fallback-host'; + +$options['unparsed_fallback'] = wp_postgresql_db_get_connection_options( $unparsed_db ); + +wp_postgresql_db_test_respond( $options ); +PHP + ); + + $this->assertSame( + array( + 'host_only' => array( + 'host' => 'postgres', + 'port' => null, + 'dbname' => 'name_host_only', + 'user' => 'user_host_only', + 'password' => 'password_host_only', + ), + 'host_port' => array( + 'host' => 'postgres', + 'port' => 6543, + 'dbname' => 'name_host_port', + 'user' => 'user_host_port', + 'password' => 'password_host_port', + ), + 'socket_file' => array( + 'host' => '/tmp', + 'port' => 6544, + 'dbname' => 'name_socket_file', + 'user' => 'user_socket_file', + 'password' => 'password_socket_file', + ), + 'explicit_port_socket_file' => array( + 'host' => '/tmp', + 'port' => 6545, + 'dbname' => 'name_explicit_port_socket_file', + 'user' => 'user_explicit_port_socket_file', + 'password' => 'password_explicit_port_socket_file', + ), + 'socket_directory' => array( + 'host' => '/var/run/postgresql', + 'port' => null, + 'dbname' => 'name_socket_directory', + 'user' => 'user_socket_directory', + 'password' => 'password_socket_directory', + ), + 'unparsed_fallback' => array( + 'host' => 'fallback-host', + 'port' => null, + 'dbname' => 'name_unparsed_fallback', + 'user' => 'user_unparsed_fallback', + 'password' => 'password_unparsed_fallback', + ), + ), + $result + ); + } + + /** + * Tests PostgreSQL connection options reuse real global PostgreSQL PDO objects. + */ + public function test_get_connection_options_reuses_global_postgresql_pdo(): void { + $this->require_pgsql_test_dsn(); + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $dbuser = 'pg_user'; + public $dbpassword = 'pg_password'; + public $dbname = 'wptests'; + public $dbhost = 'postgres:5432'; + + public function parse_db_host( $host ) { + return array( 'postgres', 5432, null, false ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +function wp_postgresql_db_get_connection_options_for_global_pdo( $global_value, $set_global ) { + if ( $set_global ) { + $GLOBALS['@pdo'] = $global_value; + } else { + unset( $GLOBALS['@pdo'] ); + } + + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_connection_options' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $options = $method->invoke( $db ); + + return array( + 'has_pdo' => array_key_exists( 'pdo', $options ), + 'pdo_same' => array_key_exists( 'pdo', $options ) ? $options['pdo'] === $global_value : null, + 'keys' => array_keys( $options ), + ); +} + +$pgsql_pdo = wp_postgresql_tests_create_pgsql_pdo(); + +wp_postgresql_db_test_respond( + array( + 'no_global' => wp_postgresql_db_get_connection_options_for_global_pdo( null, false ), + 'pgsql_pdo' => wp_postgresql_db_get_connection_options_for_global_pdo( $pgsql_pdo, true ), + 'non_pdo_value' => wp_postgresql_db_get_connection_options_for_global_pdo( (object) array( 'driver' => 'pgsql' ), true ), + ) +); +PHP + ); + + $base_keys = array( 'host', 'port', 'dbname', 'user', 'password' ); + + $this->assertSame( + array( + 'no_global' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + 'pgsql_pdo' => array( + 'has_pdo' => true, + 'pdo_same' => true, + 'keys' => array_merge( $base_keys, array( 'pdo' ) ), + ), + 'non_pdo_value' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + ), + $result + ); + } + + /** + * Tests select() uses the current PostgreSQL driver when no handle is passed. + */ + public function test_select_uses_current_postgresql_driver_when_handle_is_omitted(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $dbname = ''; + public $ready = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Select_Fake_Driver extends WP_PostgreSQL_Driver { + public function __construct() {} +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$db->dbname = 'wptests'; + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, new WP_PostgreSQL_DB_Select_Fake_Driver() ); + +$select_other_default_result = $db->select( 'other' ); +$ready_after_other_default = $db->ready; + +$select_current_default_result = $db->select( 'wptests' ); +$ready_after_current_default = $db->ready; + +$driver_property->setValue( $db, null ); +$db->ready = true; +$select_missing_driver_result = $db->select( 'wptests' ); +$ready_after_missing_driver = $db->ready; + +wp_postgresql_db_test_respond( + array( + 'select_other_default_result' => $select_other_default_result, + 'ready_after_other_default' => $ready_after_other_default, + 'select_current_default_result' => $select_current_default_result, + 'ready_after_current_default' => $ready_after_current_default, + 'select_missing_driver_result' => $select_missing_driver_result, + 'ready_after_missing_driver' => $ready_after_missing_driver, + ) +); +PHP + ); + + $this->assertSame( + array( + 'select_other_default_result' => false, + 'ready_after_other_default' => false, + 'select_current_default_result' => true, + 'ready_after_current_default' => true, + 'select_missing_driver_result' => false, + 'ready_after_missing_driver' => false, + ), + $result + ); + } + + /** + * Tests db_connect() rejects missing PostgreSQL database names before connecting. + */ + public function test_db_connect_rejects_missing_database_name_before_connecting(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $dbname = null; + public $ready = true; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$null_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$null_result = $null_db->db_connect( false ); +$null_db_bail_calls = $null_db->bail_calls; +$null_db_last_error = $null_db->last_error; +$null_db_ready = $null_db->ready; +$null_db_is_mysql = $null_db->is_mysql; +$null_db_charset = $null_db->charset; + +$empty_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$empty_db->dbname = ''; +$empty_result = $empty_db->db_connect( true ); +$empty_db_bail_calls = $empty_db->bail_calls; +$empty_db_last_error = $empty_db->last_error; +$empty_db_ready = $empty_db->ready; +$empty_db_is_mysql = $empty_db->is_mysql; +$empty_db_charset = $empty_db->charset; + +wp_postgresql_db_test_respond( + array( + 'null_result' => $null_result, + 'null_bail_calls' => $null_db_bail_calls, + 'null_last_error' => $null_db_last_error, + 'null_ready' => $null_db_ready, + 'null_is_mysql' => $null_db_is_mysql, + 'null_charset' => $null_db_charset, + 'empty_result' => $empty_result, + 'empty_bail_calls' => $empty_db_bail_calls, + 'empty_last_error' => $empty_db_last_error, + 'empty_ready' => $empty_db_ready, + 'empty_is_mysql' => $empty_db_is_mysql, + 'empty_charset' => $empty_db_charset, + ) +); +PHP + ); + + $expected_error = 'The database name was not set. The PostgreSQL backend requires DB_NAME.'; + + $this->assertSame( + array( + 'null_result' => false, + 'null_bail_calls' => array(), + 'null_last_error' => $expected_error, + 'null_ready' => false, + 'null_is_mysql' => true, + 'null_charset' => 'utf8mb4', + 'empty_result' => false, + 'empty_bail_calls' => array( + array( $expected_error, 'db_connect_fail' ), + ), + 'empty_last_error' => $expected_error, + 'empty_ready' => false, + 'empty_is_mysql' => true, + 'empty_charset' => 'utf8mb4', + ), + $result + ); + } + + /** + * Tests db_connect() maps connection option failures to wpdb state. + */ + public function test_db_connect_maps_connection_option_failures_to_wpdb_state(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $dbuser = 'pg_user'; + public $dbpassword = 'pg_password'; + public $dbname = 'wptests'; + public $dbhost = 'bad;host'; + public $ready = true; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function parse_db_host( $host ) { + return false; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +unset( $GLOBALS['@pdo'] ); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$dbh_property->setAccessible( true ); + +$non_bailing_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$non_bailing_result = $non_bailing_db->db_connect( false ); +$non_bailing_dbh_after = $dbh_property->getValue( $non_bailing_db ); +$non_bailing_bail_calls = $non_bailing_db->bail_calls; +$non_bailing_last_error = $non_bailing_db->last_error; +$non_bailing_ready = $non_bailing_db->ready; +$non_bailing_is_mysql = $non_bailing_db->is_mysql; +$non_bailing_charset = $non_bailing_db->charset; + +$bailing_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$bailing_result = $bailing_db->db_connect( true ); +$bailing_dbh_after = $dbh_property->getValue( $bailing_db ); +$bailing_bail_calls = $bailing_db->bail_calls; +$bailing_last_error = $bailing_db->last_error; +$bailing_ready = $bailing_db->ready; +$bailing_is_mysql = $bailing_db->is_mysql; +$bailing_charset = $bailing_db->charset; + +wp_postgresql_db_test_respond( + array( + 'non_bailing_result' => $non_bailing_result, + 'non_bailing_dbh_null' => null === $non_bailing_dbh_after, + 'non_bailing_bail_calls' => $non_bailing_bail_calls, + 'non_bailing_last_error' => $non_bailing_last_error, + 'non_bailing_ready' => $non_bailing_ready, + 'non_bailing_is_mysql' => $non_bailing_is_mysql, + 'non_bailing_charset' => $non_bailing_charset, + 'bailing_result' => $bailing_result, + 'bailing_dbh_null' => null === $bailing_dbh_after, + 'bailing_bail_calls' => $bailing_bail_calls, + 'bailing_last_error' => $bailing_last_error, + 'bailing_ready' => $bailing_ready, + 'bailing_is_mysql' => $bailing_is_mysql, + 'bailing_charset' => $bailing_charset, + ) +); +PHP + ); + + $expected_error = 'PostgreSQL DSN parts cannot contain NUL bytes or semicolons.'; + + $this->assertSame( + array( + 'non_bailing_result' => false, + 'non_bailing_dbh_null' => true, + 'non_bailing_bail_calls' => array(), + 'non_bailing_last_error' => $expected_error, + 'non_bailing_ready' => false, + 'non_bailing_is_mysql' => true, + 'non_bailing_charset' => 'utf8mb4', + 'bailing_result' => false, + 'bailing_dbh_null' => true, + 'bailing_bail_calls' => array( + array( $expected_error, 'db_connect_fail' ), + ), + 'bailing_last_error' => $expected_error, + 'bailing_ready' => false, + 'bailing_is_mysql' => true, + 'bailing_charset' => 'utf8mb4', + ), + $result + ); + } + + /** + * Tests check_connection() probes an existing driver and reconnects after failure. + */ + public function test_check_connection_probes_existing_driver_and_reconnects_after_failure(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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-postgresql.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, null ); + +wp_postgresql_db_test_respond( + array( + 'server_info' => $db->db_server_info(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'server_info' => 'PostgreSQL backend pending connection', + ), + $result + ); + } + + /** + * Tests query() returns before the driver for not-ready and empty queries. + */ + public function test_query_returns_false_before_driver_for_not_ready_and_empty_queries(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +$GLOBALS['wp_postgresql_db_query_filter_inputs'] = array(); + +function apply_filters( $hook_name, $value ) { + if ( 'query' !== $hook_name ) { + return $value; + } + + $GLOBALS['wp_postgresql_db_query_filter_inputs'][] = $value; + if ( 'FILTER_TO_EMPTY' === $value ) { + return ''; + } + + return $value; +} + +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $last_error = ''; + public $result = null; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Query_Early_Return_Fake_Driver extends WP_PostgreSQL_Driver { + public $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return array(); + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Query_Early_Return_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); +} +$driver_property->setValue( $db, $driver ); + +$db->ready = false; +$db->insert_id = 123; +$not_ready = array( + 'return' => $db->query( 'SELECT 1' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_input_count' => count( $GLOBALS['wp_postgresql_db_query_filter_inputs'] ), +); + +$db->ready = true; +$db->insert_id = 456; +$empty_query = array( + 'return' => $db->query( '' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_inputs' => $GLOBALS['wp_postgresql_db_query_filter_inputs'], +); + +$db->insert_id = 789; +$filter_cancelled = array( + 'return' => $db->query( 'FILTER_TO_EMPTY' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_inputs' => $GLOBALS['wp_postgresql_db_query_filter_inputs'], +); + +wp_postgresql_db_test_respond( + array( + 'not_ready' => $not_ready, + 'empty_query' => $empty_query, + 'filter_cancelled' => $filter_cancelled, + ) +); +PHP + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 123, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_input_count' => 0, + ), + $result['not_ready'] + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 0, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_inputs' => array( '' ), + ), + $result['empty_query'] + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 0, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_inputs' => array( '', 'FILTER_TO_EMPTY' ), + ), + $result['filter_cancelled'] + ); + } + + /** + * Tests query state, metadata, and SAVEQUERIES mapping. + */ + public function test_query_maps_backend_state_to_wpdb_fields(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +define( 'SAVEQUERIES', true ); + +require_once getcwd() . '/bootstrap-postgresql.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 missing active-prefix options and DESCRIBE probes return an empty install state. + */ + public function test_query_returns_empty_for_missing_current_prefix_options_install_probes(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + public $options = 'wp_e2e_options'; + public $prefix = 'wp_e2e_'; + + public function get_caller() { + return 'wpdb-install-state-test'; + } + + public function add_placeholder_escape( $query ) { + return $query; + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Install_State_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $existing_tables; + private $queries = array(); + + public function __construct( array $existing_tables ) { + $this->pdo = new PDO( 'sqlite::memory:' ); + $this->existing_tables = array_fill_keys( array_map( 'strtolower', $existing_tables ), true ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + $table = strtolower( (string) ( $params[0] ?? '' ) ); + return $this->pdo->query( isset( $this->existing_tables[ $table ] ) ? 'SELECT 1' : 'SELECT 1 WHERE 0' ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +class WP_PostgreSQL_DB_Install_State_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Install_State_Fake_Connection $connection ) { + $this->connection = $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 ( false !== strpos( $query, 'wp_e2e_' ) ) { + preg_match( '/wp_e2e_[A-Za-z0-9_]+/', $query, $matches ); + $table = $matches[0] ?? 'wp_e2e_options'; + throw new RuntimeException( sprintf( 'relation "%s" does not exist', $table ) ); + } + + if ( false !== strpos( $query, 'option_name, option_value' ) ) { + return array( + (object) array( + 'option_name' => 'siteurl', + 'option_value' => 'http://existing.example', + ), + ); + } + + return array( + (object) array( + 'option_value' => 'http://existing.example', + ), + ); + } + + 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_recorded_queries(): array { + return $this->queries; + } +} + +function wp_postgresql_db_install_probe_result( + WP_PostgreSQL_DB $db, + WP_PostgreSQL_DB_Install_State_Fake_Driver $driver, + WP_PostgreSQL_DB_Install_State_Fake_Connection $connection, + string $query, + bool $suppress_errors = true +): array { + $driver_query_count = count( $driver->get_recorded_queries() ); + $catalog_query_count = count( $connection->get_recorded_queries() ); + $error_count = count( $GLOBALS['EZSQL_ERROR'] ); + + $db->suppress_errors = $suppress_errors; + $return = $db->query( $query ); + + $catalog_queries = array_slice( $connection->get_recorded_queries(), $catalog_query_count ); + + return array( + 'return' => $return, + 'last_error' => $db->last_error, + 'num_rows' => $db->num_rows, + 'last_result' => array_map( 'get_object_vars', $db->last_result ), + 'driver_queries' => array_slice( $driver->get_recorded_queries(), $driver_query_count ), + 'catalog_query_params' => array_map( + static function ( $catalog_query ) { + return $catalog_query['params']; + }, + $catalog_queries + ), + 'errors' => array_slice( $GLOBALS['EZSQL_ERROR'], $error_count ), + ); +} + +global $EZSQL_ERROR; +$EZSQL_ERROR = array(); + +$connection = new WP_PostgreSQL_DB_Install_State_Fake_Connection( array( 'wp_options' ) ); +$driver = new WP_PostgreSQL_DB_Install_State_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 ); + +$db->ready = true; +$db->suppress_errors = true; +$db->options = 'wp_e2e_options'; + +$missing_queries = array( + 'siteurl_limit' => "SELECT option_value FROM wp_e2e_options WHERE option_name = 'siteurl' LIMIT 1", + 'home_no_limit' => "SELECT option_value FROM wp_e2e_options WHERE option_name = 'home'", + 'permalink_limit' => "SELECT option_value FROM `wp_e2e_options` WHERE `option_name` = 'permalink_structure' LIMIT 1", + 'timezone_no_limit' => "SELECT `option_value` FROM `wp_e2e_options` WHERE `option_name` = 'timezone_string'", + 'alloptions_autoload' => "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + 'alloptions_full' => 'SELECT option_name, option_value FROM wp_e2e_options', + 'describe_posts' => 'DESCRIBE wp_e2e_posts;', + 'desc_options' => 'DESC `wp_e2e_options`', +); + +$missing_results = array(); +foreach ( $missing_queries as $name => $query ) { + $missing_results[ $name ] = wp_postgresql_db_install_probe_result( $db, $driver, $connection, $query ); +} + +$old_prefix_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1" +); + +$old_prefix_alloptions_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_name, option_value FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')" +); + +$old_prefix_describe_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + 'DESCRIBE wp_options;' +); + +$db->options = 'wp_options'; +$db->prefix = 'wp_'; +$existing_active_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1" +); + +$existing_active_alloptions_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_name, option_value FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')" +); + +$existing_active_describe_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + 'DESCRIBE wp_options;' +); + +$db->options = 'wp_e2e_options'; +$db->prefix = 'wp_e2e_'; +$near_miss_queries = array( + 'different_selected_column' => "SELECT option_name FROM wp_e2e_options WHERE option_name = 'siteurl' LIMIT 1", + 'different_predicate' => "SELECT option_value FROM wp_e2e_options WHERE autoload = 'yes' LIMIT 1", + 'alias' => "SELECT option_value FROM wp_e2e_options o WHERE option_name = 'siteurl' LIMIT 1", + 'join' => "SELECT option_value FROM wp_e2e_options INNER JOIN wp_posts ON wp_posts.ID = wp_e2e_options.option_id WHERE option_name = 'siteurl' LIMIT 1", + 'alloptions_column' => "SELECT option_name, autoload FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + 'alloptions_alias' => "SELECT option_name, option_value FROM wp_e2e_options o WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + 'alloptions_join' => "SELECT option_name, option_value FROM wp_e2e_options INNER JOIN wp_posts ON wp_posts.ID = wp_e2e_options.option_id WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + 'alloptions_extra' => "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto') AND option_name = 'siteurl'", + 'alloptions_list' => "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes')", + 'describe_column' => 'DESCRIBE wp_e2e_posts ID', + 'describe_predicate' => 'DESC wp_e2e_posts WHERE Field = \'ID\'', + 'describe_qualified' => 'DESCRIBE currentdb.wp_e2e_posts', + 'describe_alias' => 'DESCRIBE wp_e2e_posts p', +); + +$near_miss_results = array(); +foreach ( $near_miss_queries as $name => $query ) { + $near_miss_results[ $name ] = wp_postgresql_db_install_probe_result( $db, $driver, $connection, $query ); +} + +$unsuppressed_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_value FROM wp_e2e_options WHERE option_name = 'siteurl' LIMIT 1", + false +); + +$unsuppressed_alloptions_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + false +); + +$unsuppressed_describe_result = wp_postgresql_db_install_probe_result( + $db, + $driver, + $connection, + 'DESCRIBE wp_e2e_posts;', + false +); + +wp_postgresql_db_test_respond( + array( + 'missing_queries' => $missing_queries, + 'missing_results' => $missing_results, + 'old_prefix_result' => $old_prefix_result, + 'old_prefix_alloptions_result' => $old_prefix_alloptions_result, + 'old_prefix_describe_result' => $old_prefix_describe_result, + 'existing_active' => $existing_active_result, + 'existing_active_alloptions' => $existing_active_alloptions_result, + 'existing_active_describe' => $existing_active_describe_result, + 'near_miss_queries' => $near_miss_queries, + 'near_miss_results' => $near_miss_results, + 'unsuppressed_result' => $unsuppressed_result, + 'unsuppressed_alloptions_result' => $unsuppressed_alloptions_result, + 'unsuppressed_describe_result' => $unsuppressed_describe_result, + ) +); +PHP + ); + + foreach ( $result['missing_results'] as $name => $case_result ) { + $expected_catalog_table = 'describe_posts' === $name ? 'wp_e2e_posts' : 'wp_e2e_options'; + $this->assertSame( 0, $case_result['return'], $name ); + $this->assertSame( '', $case_result['last_error'], $name ); + $this->assertSame( 0, $case_result['num_rows'], $name ); + $this->assertSame( array(), $case_result['last_result'], $name ); + $this->assertSame( array(), $case_result['driver_queries'], $name ); + $this->assertSame( array( array( $expected_catalog_table ) ), $case_result['catalog_query_params'], $name ); + $this->assertSame( array(), $case_result['errors'], $name ); + } + + $this->assertSame( 1, $result['old_prefix_result']['return'] ); + $this->assertSame( + array( + array( + 'option_value' => 'http://existing.example', + ), + ), + $result['old_prefix_result']['last_result'] + ); + $this->assertSame( + array( + "SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1", + ), + $result['old_prefix_result']['driver_queries'] + ); + $this->assertSame( array(), $result['old_prefix_result']['catalog_query_params'] ); + + $this->assertSame( 1, $result['old_prefix_alloptions_result']['return'] ); + $this->assertSame( + array( + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://existing.example', + ), + ), + $result['old_prefix_alloptions_result']['last_result'] + ); + $this->assertSame( + array( + "SELECT option_name, option_value FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + ), + $result['old_prefix_alloptions_result']['driver_queries'] + ); + $this->assertSame( array(), $result['old_prefix_alloptions_result']['catalog_query_params'] ); + + $this->assertSame( 1, $result['old_prefix_describe_result']['return'] ); + $this->assertSame( + array( + 'DESCRIBE wp_options;', + ), + $result['old_prefix_describe_result']['driver_queries'] + ); + $this->assertSame( array(), $result['old_prefix_describe_result']['catalog_query_params'] ); + + $this->assertSame( 1, $result['existing_active']['return'] ); + $this->assertSame( + array( + array( + 'option_value' => 'http://existing.example', + ), + ), + $result['existing_active']['last_result'] + ); + $this->assertSame( + array( + "SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1", + ), + $result['existing_active']['driver_queries'] + ); + $this->assertSame( array( array( 'wp_options' ) ), $result['existing_active']['catalog_query_params'] ); + + $this->assertSame( 1, $result['existing_active_alloptions']['return'] ); + $this->assertSame( + array( + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://existing.example', + ), + ), + $result['existing_active_alloptions']['last_result'] + ); + $this->assertSame( + array( + "SELECT option_name, option_value FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + ), + $result['existing_active_alloptions']['driver_queries'] + ); + $this->assertSame( array( array( 'wp_options' ) ), $result['existing_active_alloptions']['catalog_query_params'] ); + + $this->assertSame( 1, $result['existing_active_describe']['return'] ); + $this->assertSame( + array( + 'DESCRIBE wp_options;', + ), + $result['existing_active_describe']['driver_queries'] + ); + $this->assertSame( array( array( 'wp_options' ) ), $result['existing_active_describe']['catalog_query_params'] ); + + foreach ( $result['near_miss_results'] as $name => $case_result ) { + $this->assertFalse( $case_result['return'], $name ); + $this->assertStringStartsWith( 'relation "wp_e2e_', $case_result['last_error'], $name ); + $this->assertSame( array( $result['near_miss_queries'][ $name ] ), $case_result['driver_queries'], $name ); + $this->assertSame( array(), $case_result['catalog_query_params'], $name ); + $this->assertSame( $result['near_miss_queries'][ $name ], $case_result['errors'][0]['query'], $name ); + $this->assertSame( $case_result['last_error'], $case_result['errors'][0]['error_str'], $name ); + } + + $this->assertFalse( $result['unsuppressed_result']['return'] ); + $this->assertSame( 'relation "wp_e2e_options" does not exist', $result['unsuppressed_result']['last_error'] ); + $this->assertSame( + array( + "SELECT option_value FROM wp_e2e_options WHERE option_name = 'siteurl' LIMIT 1", + ), + $result['unsuppressed_result']['driver_queries'] + ); + $this->assertSame( array(), $result['unsuppressed_result']['catalog_query_params'] ); + $this->assertSame( + "SELECT option_value FROM wp_e2e_options WHERE option_name = 'siteurl' LIMIT 1", + $result['unsuppressed_result']['errors'][0]['query'] + ); + $this->assertSame( 'relation "wp_e2e_options" does not exist', $result['unsuppressed_result']['errors'][0]['error_str'] ); + + $this->assertFalse( $result['unsuppressed_alloptions_result']['return'] ); + $this->assertSame( 'relation "wp_e2e_options" does not exist', $result['unsuppressed_alloptions_result']['last_error'] ); + $this->assertSame( + array( + "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + ), + $result['unsuppressed_alloptions_result']['driver_queries'] + ); + $this->assertSame( array(), $result['unsuppressed_alloptions_result']['catalog_query_params'] ); + $this->assertSame( + "SELECT option_name, option_value FROM wp_e2e_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')", + $result['unsuppressed_alloptions_result']['errors'][0]['query'] + ); + $this->assertSame( 'relation "wp_e2e_options" does not exist', $result['unsuppressed_alloptions_result']['errors'][0]['error_str'] ); + + $this->assertFalse( $result['unsuppressed_describe_result']['return'] ); + $this->assertSame( 'relation "wp_e2e_posts" does not exist', $result['unsuppressed_describe_result']['last_error'] ); + $this->assertSame( + array( + 'DESCRIBE wp_e2e_posts;', + ), + $result['unsuppressed_describe_result']['driver_queries'] + ); + $this->assertSame( array(), $result['unsuppressed_describe_result']['catalog_query_params'] ); + $this->assertSame( + 'DESCRIBE wp_e2e_posts;', + $result['unsuppressed_describe_result']['errors'][0]['query'] + ); + $this->assertSame( 'relation "wp_e2e_posts" does not exist', $result['unsuppressed_describe_result']['errors'][0]['error_str'] ); + } + + /** + * Tests the Site Health table-size fast path and SAVEQUERIES catalog logging. + */ + public function test_query_uses_site_health_table_size_fast_path(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +define( 'SAVEQUERIES', true ); + +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = true, $queries = array(), $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, ...$args ) { $this->queries[] = array( 'query' => $query ); } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Site_Health_Connection extends WP_PostgreSQL_Connection { + public $pdo; + public $queries = array(); + + public function __construct() { $this->pdo = new PDO( 'sqlite::memory:' ); } + + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( $sql, $params ); + return $this->pdo->query( + "SELECT 'wptests_options' AS \"table\", 12 AS \"rows\", 34 AS \"bytes\" UNION ALL SELECT 'wptests_posts', 56, 78" + ); + } +} + +class WP_PostgreSQL_DB_Site_Health_Driver extends WP_PostgreSQL_Driver { + public $connection; + public $queries = array(); + public $last_postgresql_queries = array( + array( + 'sql' => 'STALE DRIVER SQL', + 'params' => array( 'stale' ), + ), + ); + + public function __construct() { $this->connection = new WP_PostgreSQL_DB_Site_Health_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_postgresql_queries = array( array( 'sql' => $query ) ); + return array( (object) array( 'table' => 'fallback' ) ); + } + + public function get_last_postgresql_queries(): array { return $this->last_postgresql_queries; } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Site_Health_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->dbname = 'wptests'; + +$base_query = "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', %s as 'bytes' FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME IN (%s) GROUP BY TABLE_NAME"; +$exact_query = sprintf( $base_query, 'SUM( data_length + index_length )', "'wptests_options', 'wptests_posts'" ); +$exact_return = $db->query( $exact_query ); +$exact_rows = array_map( 'get_object_vars', $db->last_result ); + +$near_miss_query = sprintf( $base_query, 'SUM( data_length + index_length + 0 )', "'wptests_options'" ); +$near_miss_return = $db->query( $near_miss_query ); + +wp_postgresql_db_test_respond( + array( + 'exact_return' => $exact_return, + 'exact_rows' => $exact_rows, + 'exact_query' => $exact_query, + 'exact_log' => $db->queries[0], + 'driver_after' => $driver->queries, + 'fast_path_after' => $driver->connection->queries, + 'near_return' => $near_miss_return, + 'near_miss_query' => $near_miss_query, + 'near_miss_log' => $db->queries[1], + ) +); +PHP + ); + + $this->assertSame( 2, $result['exact_return'] ); + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), array_column( $result['exact_rows'], 'table' ) ); + $this->assertSame( array( 12, 56 ), array_column( $result['exact_rows'], 'rows' ) ); + $this->assertSame( array( 34, 78 ), array_column( $result['exact_rows'], 'bytes' ) ); + $exact_postgresql_query = $result['exact_log']['postgresql_queries'][0]; + $this->assertSame( $result['exact_query'], $result['exact_log']['query'] ); + $this->assertStringContainsString( 'pg_catalog.pg_class', $exact_postgresql_query['sql'] ); + $this->assertStringContainsString( 'pg_catalog.pg_namespace', $exact_postgresql_query['sql'] ); + $this->assertNotSame( 'STALE DRIVER SQL', $exact_postgresql_query['sql'] ); + $this->assertSame( + array( 'public', 'wptests_options', 'wptests_posts' ), + $exact_postgresql_query['params'] + ); + $this->assertSame( array( $result['near_miss_query'] ), $result['driver_after'] ); + $this->assertCount( 1, $result['fast_path_after'] ); + $this->assertSame( + array( 'public', 'wptests_options', 'wptests_posts' ), + $result['fast_path_after'][0][1] + ); + + $this->assertSame( 1, $result['near_return'] ); + $this->assertSame( $result['near_miss_query'], $result['near_miss_log']['query'] ); + $this->assertSame( + $result['near_miss_query'], + $result['near_miss_log']['postgresql_queries'][0]['sql'] + ); + } + + /** + * Tests query() detects write statements after leading SQL comments. + */ + public function test_query_detects_statement_keyword_after_leading_sql_comments(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.php'; + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + + public function get_caller() { + return 'wpdb-leading-comments-test'; + } + + public function add_placeholder_escape( $query ) { + return $query; + } +} + +global $EZSQL_ERROR; +$EZSQL_ERROR = array(); + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Leading_Comments_Fake_Driver extends WP_PostgreSQL_Driver { + private $insert_id = 0; + private $last_return_value = 0; + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( false !== strpos( $query, 'broken' ) ) { + throw new RuntimeException( 'synthetic comment-prefixed failure' ); + } + + $this->insert_id = 42; + $this->last_return_value = 3; + return 3; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Leading_Comments_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); +$db->ready = true; + +$commented_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO probe (label) VALUES ('ok')"; +$insert_return = $db->query( $commented_insert ); +$insert = array( + 'return' => $insert_return, + 'rows_affected' => $db->rows_affected, + 'insert_id' => $db->insert_id, + 'num_rows' => $db->num_rows, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), +); + +$db->insert_id = 99; + +$commented_failed_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO broken (label) VALUES ('bad')"; +$failed_return = $db->query( $commented_failed_insert ); +$failed_insert = array( + 'return' => $failed_return, + 'last_error' => $db->last_error, + 'insert_id' => $db->insert_id, + 'rows_affected' => $db->rows_affected, + 'num_rows' => $db->num_rows, + 'queries' => $driver->get_recorded_queries(), + 'errors' => $EZSQL_ERROR, +); + +wp_postgresql_db_test_respond( + array( + 'commented_insert' => $commented_insert, + 'commented_failed_insert' => $commented_failed_insert, + 'insert' => $insert, + 'failed_insert' => $failed_insert, + ) +); +PHP + ); + + $commented_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO probe (label) VALUES ('ok')"; + $commented_failed_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO broken (label) VALUES ('bad')"; + + $this->assertSame( $commented_insert, $result['commented_insert'] ); + $this->assertSame( + array( + 'return' => 3, + 'rows_affected' => 3, + 'insert_id' => 42, + 'num_rows' => 0, + 'last_error' => '', + 'queries' => array( + $commented_insert, + ), + ), + $result['insert'] + ); + + $this->assertSame( $commented_failed_insert, $result['commented_failed_insert'] ); + $this->assertSame( + array( + 'return' => false, + 'last_error' => 'synthetic comment-prefixed failure', + 'insert_id' => 0, + 'rows_affected' => 0, + 'num_rows' => 0, + 'queries' => array( + $commented_insert, + $commented_failed_insert, + ), + 'errors' => array( + array( + 'query' => $commented_failed_insert, + 'error_str' => 'synthetic comment-prefixed failure', + ), + ), + ), + $result['failed_insert'] + ); + } + + /** + * Tests the SQL generated by real wpdb helper methods before the driver sees it. + */ + public function test_real_wpdb_update_and_delete_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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-postgresql.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Empty_Where_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + return 1; + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +function wp_postgresql_db_test_empty_where_result( string $query ): array { + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $driver = new WP_PostgreSQL_DB_Empty_Where_Fake_Driver(); + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + $driver_property->setValue( $db, $driver ); + + $db->ready = true; + $db->is_mysql = false; + $db->dbname = 'wptests'; + $db->charset = 'utf8mb4'; + $db->suppress_errors = true; + + $return = $db->query( $query ); + + return array( + 'return' => $return, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), + 'rows_affected' => $db->rows_affected, + 'num_queries' => $db->num_queries, + ); +} + +$queries = array( + 'plain' => "UPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'block' => "/* plugin preamble */\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'dash' => "-- plugin preamble\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'hash' => "# plugin preamble\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", +); + +$results = array(); +foreach ( $queries as $name => $query ) { + $results[ $name ] = wp_postgresql_db_test_empty_where_result( $query ); +} + +wp_postgresql_db_test_respond( + array( + 'results' => $results, + ) +); +PHP + ); + + foreach ( $result['results'] as $case => $case_result ) { + $this->assertFalse( $case_result['return'], $case ); + $this->assertSame( + 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.', + $case_result['last_error'], + $case + ); + $this->assertSame( array(), $case_result['queries'], $case ); + $this->assertSame( 0, $case_result['rows_affected'], $case ); + $this->assertSame( 0, $case_result['num_queries'], $case ); + } + } + + /** + * Tests the SQL generated by real wpdb insert helpers before the driver sees it. + */ + public function test_real_wpdb_insert_and_replace_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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-postgresql.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'] + ); + } + + /** + * Requires a real PostgreSQL test DSN before spawning isolated scripts. + */ + private function require_pgsql_test_dsn(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run PostgreSQL-backed wpdb adapter tests.' ); + } + } + + /** + * 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_RegExp_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php new file mode 100644 index 000000000..53d0d7096 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -0,0 +1,262 @@ +create_driver(); + $query = "DELETE FROM `wptests_options` WHERE `option_name` REGEXP '^_transient_feed_'"; + + $this->assertSame( + 'DELETE FROM "wptests_options" WHERE "option_name" ~* \'^_transient_feed_\'', + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + ); + } + + /** + * 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 the real PostgreSQL test connection. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver(): WP_PostgreSQL_Driver { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL REGEXP translation tests.' ); + } + + $pdo = new PDO( + $dsn, + (string) getenv( 'PGSQL_TEST_USER' ), + (string) getenv( 'PGSQL_TEST_PASSWORD' ) + ); + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + return new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + '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_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php new file mode 100644 index 000000000..6e713e772 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -0,0 +1,35470 @@ + + */ + private $real_pgsql_test_schemas = array(); + + /** + * Drop isolated real PostgreSQL schemas created during the test. + */ + protected function tearDown(): void { + foreach ( array_reverse( $this->real_pgsql_test_schemas ) as $cleanup ) { + try { + $pdo = $cleanup['pdo']; + if ( $pdo->inTransaction() ) { + $pdo->rollBack(); + } + + $pdo->exec( + 'DROP SCHEMA IF EXISTS ' . + WP_PostgreSQL_Connection::quote_identifier_value( $cleanup['schema'] ) . + ' CASCADE' + ); + } catch ( Throwable $e ) { + // Cleanup should not mask the test result. + } + } + + $this->real_pgsql_test_schemas = array(); + parent::tearDown(); + } + + /** + * Tests SELECT queries return fetched rows and normalized metadata. + */ + public function test_query_returns_rows_and_metadata(): void { + $driver = $this->create_real_pgsql_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(), + ), + ), + $this->get_last_schema_postgresql_queries( $driver ) + ); + + $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 single-quoted SELECT aliases are translated to PostgreSQL identifiers. + */ + public function test_select_single_quoted_projection_aliases_translate_to_postgresql_identifiers(): void { + $driver = $this->create_real_pgsql_driver(); + + $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_comments','wptests_options','wptests_posts','wptests_terms','wptests_users') + GROUP BY TABLE_NAME" + ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringNotContainsString( "AS 'table'", $sql ); + $this->assertStringNotContainsString( "AS 'rows'", $sql ); + $this->assertStringNotContainsString( "as 'bytes'", $sql ); + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( 'AS "rows"', $sql ); + $this->assertStringContainsString( 'as "bytes"', $sql ); + $this->assertStringContainsString( "'wordpress_develop_tests'", $sql ); + $this->assertStringContainsString( "'wptests_posts'", $sql ); + + $rows = $driver->query( "SELECT 'table' AS 'alias', 1 AS id WHERE 'rows' = 'rows'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'table', $rows[0]->alias ); + $this->assertSame( + "SELECT 'table' AS \"alias\", 1 AS id WHERE 'rows' = 'rows'", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * 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 id FROM t USE INDEX FOR GROUP BY (i) GROUP BY id HAVING id = 1' => 'SELECT id FROM t GROUP BY id HAVING id = 1', + 'SELECT t.id 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 t.id = 1 GROUP BY t.id HAVING t.id = 1 ORDER BY t.id DESC' => 'SELECT t.id FROM "t" JOIN j ON t.id = j.t_id WHERE t.id = 1 GROUP BY t.id HAVING t.id = 1 ORDER BY t.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_real_pgsql_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 result fetching avoids fetchAll() only for plain row fetch modes. + */ + public function test_result_fetch_helper_uses_incremental_path_only_for_plain_row_modes(): void { + $driver = ( new ReflectionClass( WP_PostgreSQL_Driver::class ) )->newInstanceWithoutConstructor(); + $method = new ReflectionMethod( WP_PostgreSQL_Driver::class, 'fetch_and_decode_postgresql_result_rows' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $first_row = (object) array( + 'id' => '1', + 'label' => 'first', + ); + $second_row = (object) array( + 'id' => '2', + 'label' => 'second', + ); + $plain_statement = new WP_PostgreSQL_Result_Materialization_Test_Statement( + array( $first_row, $second_row ) + ); + $plain_fetch_rows = $method->invoke( + $driver, + $plain_statement, + PDO::FETCH_OBJ, + array() + ); + + $this->assertSame( array( $first_row, $second_row ), $plain_fetch_rows ); + $this->assertSame( array( PDO::FETCH_OBJ, PDO::FETCH_OBJ, PDO::FETCH_OBJ ), $plain_statement->fetch_modes ); + $this->assertSame( array(), $plain_statement->fetch_all_modes ); + + $grouped_fetch_rows = array( + 'odd' => array( + array( + 'label' => 'first', + ), + ), + ); + $grouped_statement = new WP_PostgreSQL_Result_Materialization_Test_Statement( + array(), + $grouped_fetch_rows + ); + $fallback_rows = $method->invoke( + $driver, + $grouped_statement, + PDO::FETCH_GROUP | PDO::FETCH_ASSOC, + array() + ); + + $this->assertSame( $grouped_fetch_rows, $fallback_rows ); + $this->assertSame( array(), $grouped_statement->fetch_modes ); + $this->assertSame( array( PDO::FETCH_GROUP | PDO::FETCH_ASSOC ), $grouped_statement->fetch_all_modes ); + $this->assertSame( array( array() ), $grouped_statement->fetch_all_args ); + } + + /** + * Tests default object row materialization preserves ordering and metadata. + */ + public function test_query_materializes_default_object_rows_without_changing_public_contract(): void { + $driver = $this->create_driver(); + + $driver->get_connection()->query( + 'CREATE TABLE result_materialization_order ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->get_connection()->query( + "INSERT INTO result_materialization_order (id, label) VALUES + (1, 'first'), + (2, 'second'), + (3, 'third')" + ); + + $rows = $driver->query( 'SELECT id, label FROM result_materialization_order ORDER BY id DESC' ); + + $row_ids = array(); + $row_labels = array(); + foreach ( $rows as $row ) { + $row_ids[] = $row->id; + $row_labels[] = $row->label; + } + + $this->assertSame( $rows, $driver->get_last_return_value() ); + $this->assertSame( array( '3', '2', '1' ), $row_ids ); + $this->assertSame( array( 'third', 'second', 'first' ), $row_labels ); + $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' ) ); + $this->assertSame( array( 'id', 'label' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + } + + /** + * Tests associative and grouped fetch modes keep their public result shapes. + */ + public function test_query_preserves_associative_and_grouped_fetch_modes(): void { + $driver = $this->create_driver(); + + $driver->get_connection()->query( + 'CREATE TABLE result_materialization_fetch_modes ( + id INTEGER PRIMARY KEY, + category TEXT NOT NULL, + label TEXT NOT NULL + )' + ); + $driver->get_connection()->query( + "INSERT INTO result_materialization_fetch_modes (id, category, label) VALUES + (1, 'odd', 'first'), + (2, 'even', 'second'), + (3, 'odd', 'third')" + ); + + $assoc_rows = $driver->query( + 'SELECT id, label FROM result_materialization_fetch_modes ORDER BY id ASC', + PDO::FETCH_ASSOC + ); + + $this->assertSame( + array( + array( + 'id' => '1', + 'label' => 'first', + ), + array( + 'id' => '2', + 'label' => 'second', + ), + array( + 'id' => '3', + 'label' => 'third', + ), + ), + $assoc_rows + ); + $this->assertSame( $assoc_rows, $driver->get_last_return_value() ); + + $grouped_rows = $driver->query( + 'SELECT category, label FROM result_materialization_fetch_modes ORDER BY category ASC, id ASC', + PDO::FETCH_GROUP | PDO::FETCH_ASSOC + ); + + $this->assertSame( + array( + 'even' => array( + array( + 'label' => 'second', + ), + ), + 'odd' => array( + array( + 'label' => 'first', + ), + array( + 'label' => 'third', + ), + ), + ), + $grouped_rows + ); + $this->assertSame( $grouped_rows, $driver->get_last_return_value() ); + } + + /** + * Tests larger result sets stay fully materialized for wpdb compatibility. + */ + public function test_query_keeps_large_results_materialized_for_wpdb_compatibility(): void { + $driver = $this->create_driver(); + + $driver->get_connection()->query( + 'CREATE TABLE result_materialization_large ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->get_connection()->query( + "INSERT INTO result_materialization_large (id, label) + SELECT g, 'row-' || CAST(g AS text) + FROM generate_series(1, 256) AS g" + ); + + $rows = $driver->query( 'SELECT id, label FROM result_materialization_large ORDER BY id ASC' ); + + $this->assertCount( 256, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'row-1', $rows[0]->label ); + $this->assertSame( '256', $rows[255]->id ); + $this->assertSame( 'row-256', $rows[255]->label ); + $this->assertSame( $rows, $driver->get_last_return_value() ); + $this->assertSame( 2, $driver->get_last_column_count() ); + } + + /** + * 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, value TEXT)' ); + $result = $driver->query( "INSERT INTO t (id, value) VALUES (1, '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\')', + $this->remove_real_pgsql_test_schema_qualifiers( $queries[0]['sql'] ) + ); + $this->assertSame( array(), $queries[0]['params'] ); + + $rows = $driver->query( "SELECT user_login FROM wptests_users WHERE user_login = 'admin'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'admin', $rows[0]->user_login ); + } + + /** + * Tests simple MySQL INSERT forms without INTO and with multi-row VALUES are translated. + */ + public function test_simple_insert_without_into_and_multi_row_values_translate_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_bulk_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + $this->assertSame( + 2, + $driver->query( "INSERT wptests_bulk_insert (`id`, `value`) VALUES (1, 'one'), (2, 'two')" ) + ); + $this->assertSame( + 'INSERT INTO "wptests_bulk_insert" ("id", "value") VALUES (1, \'one\'), (2, \'two\')', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + + $this->assertSame( + 1, + $driver->query( "INSERT IGNORE wptests_bulk_insert (`id`, `value`) VALUES (2, 'duplicate'), (3, 'three')" ) + ); + $this->assertSame( + 'INSERT INTO "wptests_bulk_insert" ("id", "value") VALUES (2, \'duplicate\'), (3, \'three\') ON CONFLICT DO NOTHING', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_bulk_insert ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'one' ), + array( '2', 'two' ), + array( '3', 'three' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests singular VALUE row-list INSERT syntax is translated like VALUES. + */ + public function test_value_keyword_insert_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_value_keyword_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + $insert = "INSERT INTO wptests_value_keyword_insert (`id`, `value`) VALUE (1, 'one')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_value_keyword_insert" ("id", "value") VALUES (1, \'one\')', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_value_keyword_insert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + } + + /** + * Tests columnless INSERT VALUES statements infer target columns from MySQL metadata. + */ + public function test_columnless_insert_values_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_columnless_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_columnless_insert ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $insert = "INSERT INTO `wptests_columnless_insert` VALUES (1, 'one'), (2, 'two')"; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_insert" ("id", "value") VALUES (1, \'one\'), (2, \'two\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_columnless_insert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'one', + ), + (object) array( + 'id' => '2', + 'value' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests INSERT IGNORE ... SELECT without INTO keeps SQLite plugin-corpus parity. + */ + public function test_insert_ignore_select_without_into_translates_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_ignore_select (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_insert_ignore_select_source (id INTEGER NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_insert_ignore_select (id, value) VALUES (1, 'existing')" ); + $driver->query( "INSERT INTO wptests_insert_ignore_select_source (id, value) VALUES (1, 'duplicate'), (2, 'new')" ); + + $insert = 'INSERT IGNORE wptests_insert_ignore_select (`id`, `value`) + SELECT id, value FROM wptests_insert_ignore_select_source ORDER BY id'; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO wptests_insert_ignore_select ("id", "value") SELECT id, value FROM wptests_insert_ignore_select_source ORDER BY id ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_ignore_select ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'existing' ), + array( '2', 'new' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests simple MySQL INSERT ... SET assignments are translated to PostgreSQL. + */ + public function test_simple_insert_set_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_set (id INTEGER PRIMARY KEY, value TEXT NOT NULL, attempts INTEGER NOT NULL)' ); + + $insert = "INSERT INTO wptests_insert_set SET id = 1, value = 'one', attempts = 2"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (1, \'one\', 2)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value, attempts FROM wptests_insert_set' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( '2', $rows[0]->attempts ); + + $insert_ignore = "INSERT IGNORE wptests_insert_set SET id = 1, value = 'ignored', attempts = 3"; + + $this->assertSame( 0, $driver->query( $insert_ignore ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (1, \'ignored\', 3) ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT value, attempts FROM wptests_insert_set WHERE id = 1' ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( '2', $rows[0]->attempts ); + + $qualified_insert = "INSERT INTO wptests_insert_set SET wptests_insert_set.id = 2, wptests_insert_set.value = 'two', wptests.wptests_insert_set.attempts = 4"; + + $this->assertSame( 1, $driver->query( $qualified_insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (2, \'two\', 4)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT value, attempts FROM wptests_insert_set WHERE id = 2' ); + $this->assertSame( 'two', $rows[0]->value ); + $this->assertSame( '4', $rows[0]->attempts ); + } + + /** + * Tests INSERT ... SET accepts unquoted keyword column names. + */ + public function test_insert_set_accepts_unquoted_name_column_target(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_set_name (id INTEGER PRIMARY KEY, name TEXT NOT NULL, attempts INTEGER NOT NULL)' ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO wptests_insert_set_name SET id = 1, name = UPPER('alpha'), attempts = 1 + 2" + ) + ); + + $rows = $driver->query( 'SELECT id, name, attempts FROM wptests_insert_set_name' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'ALPHA', $rows[0]->name ); + $this->assertSame( '3', $rows[0]->attempts ); + } + + /** + * Tests MySQL INSERT priority modifiers are accepted as compatibility no-ops. + */ + public function test_insert_priority_modifiers_are_accepted_as_noops(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_priority (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_insert_priority_source (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_insert_priority_source (id, value) VALUES (2, 'delayed-select')" ); + + $this->assertSame( 1, $driver->query( "INSERT LOW_PRIORITY INTO wptests_insert_priority (id, value) VALUES (1, 'low')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (1, \'low\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "INSERT HIGH_PRIORITY IGNORE INTO wptests_insert_priority (id, value) VALUES (1, 'ignored')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (1, \'ignored\') ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT DELAYED INTO wptests_insert_priority (id, value) + SELECT id, value FROM wptests_insert_priority_source' + ) + ); + $this->assertSame( + 'INSERT INTO wptests_insert_priority (id, value) SELECT id, value FROM wptests_insert_priority_source', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertStringNotContainsString( 'DELAYED', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 1, $driver->query( "INSERT LOW_PRIORITY INTO wptests_insert_priority SET id = 3, value = 'low-set'" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (3, \'low-set\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_priority ORDER BY id' ); + $this->assertSame( array( '1', '2', '3' ), array_column( $rows, 'id' ) ); + $this->assertSame( array( 'low', 'delayed-select', 'low-set' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests unsupported INSERT ... SET shapes fail before backend execution. + */ + public function test_unsupported_insert_set_shapes_fail_closed_before_backend(): void { + $queries = array( + 'INSERT INTO wptests_insert_set SET id = 1, id = 2', + 'INSERT INTO wptests_insert_set SET other_table.id = 1', + 'INSERT INTO wptests_insert_set SET other_db.wptests_insert_set.id = 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported INSERT statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported INSERT shapes fail before backend execution. + */ + public function test_unsupported_insert_shapes_fail_closed_before_backend(): void { + $queries = array( + 'INSERT INTO wptests_insert_unsupported PARTITION (p0) (id) VALUES (1)', + 'INSERT INTO wptests_insert_unsupported (id) VALUES (1) RETURNING id', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported INSERT statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests non-strict INSERT statements append metadata-derived NOT NULL defaults. + */ + public function test_non_strict_insert_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_author_email TEXT NOT NULL, + comment_content TEXT NOT NULL, + comment_parent INTEGER NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "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(), + ), + ), + $this->get_last_schema_postgresql_queries( $driver ) + ); + + $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 + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "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(), + ), + ), + $this->get_last_schema_postgresql_queries( $driver ) + ); + + $posts = $driver->query( 'SELECT post_date, post_content, post_excerpt, post_status, post_parent FROM wptests_posts WHERE ID = 1' ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '', $posts[0]->post_content ); + $this->assertSame( '', $posts[0]->post_excerpt ); + $this->assertSame( 'publish', $posts[0]->post_status ); + $this->assertSame( '0', $posts[0]->post_parent ); + } + + /** + * Tests non-strict INSERT translates CURRENT_TIMESTAMP metadata defaults as expressions. + */ + public function test_non_strict_insert_translates_current_timestamp_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_insert_current_timestamp_defaults ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_insert_current_timestamp_defaults ( + id bigint(20) unsigned NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT (now()), + PRIMARY KEY (id) + )' + ); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_simple_mysql_insert_query', + 'INSERT INTO `wptests_insert_current_timestamp_defaults` (`id`) VALUES (1)' + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + 'INSERT INTO "wptests_insert_current_timestamp_defaults" ("id", "created_at", "updated_at") VALUES (1, TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'), TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'))', + $translation['sql'] + ); + } + + /** + * Tests MySQL tokenization reuses the most recent query token stream. + */ + public function test_mysql_token_cache_reuses_most_recent_query_tokens(): void { + $driver = $this->create_backendless_driver(); + $get_tokens = Closure::bind( + function ( string $query ): array { + return $this->get_mysql_tokens( $query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $first_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $second_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $third_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 2' ); + + $this->assertSame( $first_tokens, $second_tokens ); + $this->assertNotSame( $first_tokens, $third_tokens ); + + $driver->set_sql_mode( 'ANSI_QUOTES' ); + $ansi_tokens = $get_tokens( 'SELECT "ID" FROM "wptests_posts"' ); + + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $ansi_tokens[1]->id ); + $this->assertNotSame( $first_tokens, $ansi_tokens ); + } + + /** + * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_insert_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $insert = "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) VALUES (1, '2020-12-41 14:15:27', '0000-00-00 00:00:00', '2020-00-15 14:15:27', '2020-06-01T12:13:14Z')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") VALUES (1, \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\', \'2020-00-15 14:15:27\', \'2020-06-01 12:13:14\')', + 'params' => array(), + ), + ), + $this->get_last_schema_postgresql_queries( $driver ) + ); + + $posts = $driver->query( 'SELECT post_date, post_date_gmt, post_modified, post_modified_gmt FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $posts ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date_gmt ); + $this->assertSame( '2020-00-15 14:15:27', $posts[0]->post_modified ); + $this->assertSame( '2020-06-01 12:13:14', $posts[0]->post_modified_gmt ); + } + + /** + * Tests strict zero-date SQL modes reject invalid date/time literals before backend execution. + */ + public function test_strict_insert_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) VALUES (1, '0000-00-00 00:00:00')" ); + $this->fail( 'Expected zero date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict INSERT accepts zero dates when NO_ZERO_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_dates_when_no_zero_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_date FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_date ); + } + + /** + * Tests non-strict INSERT accepts zero dates when NO_ZERO_DATE is enabled without strict mode. + */ + public function test_non_strict_insert_accepts_zero_dates_when_no_zero_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_date FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_date ); + } + + /** + * Tests strict DATE columns accept zero dates when NO_ZERO_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_dates_for_date_columns_when_no_zero_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( "INSERT INTO `wptests_strict_values` (`id`, `date_value`) VALUES (1, '0000-00-00')" ) + ); + + $rows = $driver->query( 'SELECT date_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + } + + /** + * Tests strict zero-date SQL modes reject INSERT ... SELECT date literals before backend execution. + */ + public function test_strict_insert_select_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) SELECT 1, '0000-00-00 00:00:00' FROM DUAL" ); + $this->fail( 'Expected zero date projection to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict zero-date SQL modes reject INSERT ... SELECT literals without FROM. + */ + public function test_strict_insert_select_without_from_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) SELECT 1, '0000-00-00 00:00:00'" ); + $this->fail( 'Expected no-FROM zero date projection to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict no-FROM INSERT ... SELECT normalizes partial-zero date/time literals. + */ + public function test_non_strict_insert_select_without_from_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + SELECT 1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00'" + ) + ); + $insert_sql = $this->get_last_single_postgresql_sql( $driver ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") SELECT 1, \'2020-01-01 00:00:00\', \'2020-01-01 00:00:00\', \'0000-00-00 00:00:00\' , \'2020-01-01 00:00:00\'', + $insert_sql + ); + } + + /** + * Tests strict zero-in-date SQL modes reject partial-zero date/time literals before backend execution. + */ + public function test_strict_update_rejects_zero_in_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE' ); + + try { + $driver->query( "UPDATE `wptests_posts` SET `post_modified` = '2020-00-15 14:15:27' WHERE `ID` = 1" ); + $this->fail( 'Expected zero-in-date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '2020-00-15 14:15:27'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict INSERT accepts partial-zero dates when NO_ZERO_IN_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_in_dates_when_no_zero_in_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ZERO_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2020-00-15 14:15:27', $rows[0]->post_modified ); + } + + /** + * Tests non-strict INSERT normalizes partial-zero dates when NO_ZERO_IN_DATE is enabled. + */ + public function test_non_strict_insert_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests strict ON DUPLICATE KEY UPDATE rejects zero-date literals before backend execution. + */ + public function test_strict_upsert_update_assignment_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + + try { + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-01-02 00:00:00') + ON DUPLICATE KEY UPDATE `post_modified` = '0000-00-00 00:00:00'" + ); + $this->fail( 'Expected zero date assignment to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes VALUES() partial-zero dates. + */ + public function test_non_strict_upsert_values_assignment_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-00-15 14:15:27', '2020-01-02 00:00:00') + ON DUPLICATE KEY UPDATE `post_modified` = VALUES(`post_modified`)" + ) + ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") VALUES (1, \'2020-01-02 00:00:00\', \'2020-01-02 00:00:00\', \'0000-00-00 00:00:00\', \'2020-01-02 00:00:00\') ON CONFLICT ("ID") DO UPDATE SET "post_modified" = excluded."post_modified"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests non-strict REPLACE ... SELECT normalizes partial-zero date/time projections. + */ + public function test_non_strict_replace_select_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "REPLACE INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + SELECT 1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-00-15 14:15:27', '2020-01-02 00:00:00' FROM DUAL" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests stored zero dates remain readable and comparable regardless of the current SQL mode. + */ + public function test_stored_zero_dates_remain_readable_comparable_and_orderable_after_modes_change(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 3, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES + (1, '0000-00-00 00:00:00', '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), + (2, '2022-06-01 00:00:00', '2022-06-01 00:00:00', '2022-06-01 00:00:00', '2022-06-01 00:00:00'), + (3, '2023-01-01 00:00:00', '2023-01-01 00:00:00', '2023-01-01 00:00:00', '2023-01-01 00:00:00')" + ) + ); + + $driver->set_sql_mode( 'DEFAULT' ); + + $matches = $driver->query( "SELECT ID FROM wptests_posts WHERE post_date = '0000-00-00 00:00:00'" ); + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $matches + ) + ); + + $older = $driver->query( "SELECT ID FROM wptests_posts WHERE post_date < '2000-01-01 00:00:00' ORDER BY post_date" ); + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $older + ) + ); + + $ordered = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY post_date ASC' ); + $this->assertSame( + array( '1', '2', '3' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $ordered + ) + ); + } + + /** + * Tests strict INSERT normalizes accepted temporal/YEAR values and rejects invalid scalar temporal values. + */ + public function test_strict_insert_normalizes_temporal_and_year_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + try { + $driver->query( 'INSERT INTO `wptests_strict_values` (`id`, `date_value`) VALUES (1, TRUE)' ); + $this->fail( 'Expected invalid date scalar to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect date value: '1'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`, `year_value`) + VALUES (2, '2025-10-23 18:30:00.123456', '2025-10-23', '2025-10-23 18:30:00.123456', 50)" + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value, year_value FROM wptests_strict_values WHERE id = 2' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2025-10-23', $rows[0]->date_value ); + $this->assertSame( '2025-10-23 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '2025-10-23 18:30:00', $rows[0]->timestamp_value ); + $this->assertSame( '2050', $rows[0]->year_value ); + + foreach ( array( '-1', '1900', '2156' ) as $value ) { + try { + $driver->query( "INSERT INTO `wptests_strict_values` (`id`, `year_value`) VALUES (3, {$value})" ); + $this->fail( 'Expected invalid YEAR value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Out of range value: '{$value}'", $e->getMessage(), $value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $value ); + } + } + } + + /** + * Tests strict UPDATE normalizes accepted temporal/YEAR values and rejects invalid scalar temporal values. + */ + public function test_strict_update_normalizes_temporal_and_year_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`, `year_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00', '2025')" + ); + + $this->assertSame( + 1, + $driver->query( + "UPDATE `wptests_strict_values` + SET `date_value` = '2025-11-24 02:03:04.999999', + `datetime_value` = '2025-11-24', + `timestamp_value` = '2025-11-24 02:03:04.999999', + `year_value` = 70 + WHERE `id` = 1" + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value, year_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2025-11-24', $rows[0]->date_value ); + $this->assertSame( '2025-11-24 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '2025-11-24 02:03:04', $rows[0]->timestamp_value ); + $this->assertSame( '1970', $rows[0]->year_value ); + + try { + $driver->query( 'UPDATE `wptests_strict_values` SET `datetime_value` = FALSE WHERE `id` = 1' ); + $this->fail( 'Expected invalid datetime scalar to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict UPDATE avoids temporal validation helpers for safe FROM_UNIXTIME() literals. + */ + public function test_strict_update_avoids_temporal_helper_for_literal_from_unixtime_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE `wptests_strict_values` + SET `date_value` = FROM_UNIXTIME(0), + `datetime_value` = FROM_UNIXTIME(0.123456), + `timestamp_value` = DATE_ADD(FROM_UNIXTIME(0), INTERVAL 1 DAY) + WHERE `id` = 1' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'YYYY-MM-DD')", $sql ); + $this->assertStringContainsString( "TO_CHAR(TO_TIMESTAMP(CAST(0.123456 AS double precision)) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')", $sql ); + $this->assertStringContainsString( "INTERVAL '1 day'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal validation helpers for fixed DATE_FORMAT() masks. + */ + public function test_strict_update_avoids_temporal_helper_for_fixed_date_format_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = DATE_FORMAT(NOW(), '%Y-%m-%d'), + `datetime_value` = DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'), + `timestamp_value` = DATE_FORMAT(CURRENT_DATE, '%Y-%m-%d') + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "'YYYY-MM-DD'", $sql ); + $this->assertStringContainsString( "'YYYY-MM-DD HH24:MI:SS'", $sql ); + $this->assertStringContainsString( "|| ' 00:00:00'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for fixed formatted FROM_UNIXTIME() literals. + */ + public function test_strict_update_avoids_temporal_helper_for_fixed_formatted_from_unixtime_literals(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = FROM_UNIXTIME(1609632000, CONCAT('%Y', '-%m-%d')), + `datetime_value` = FROM_UNIXTIME(1609632000, CONCAT_WS(' ', '%Y-%m-%d', '%H:%i:%s')), + `timestamp_value` = FROM_UNIXTIME(1609632000, CONCAT('%Y', '-%m-%d')) + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'TO_TIMESTAMP(CAST(1609632000 AS double precision))', $sql ); + $this->assertStringContainsString( "'YYYY'", $sql ); + $this->assertStringContainsString( "'HH24'", $sql ); + $this->assertStringContainsString( "|| ' 00:00:00'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for expressions based on valid temporal literals. + */ + public function test_strict_update_avoids_temporal_helper_for_valid_literal_temporal_sources(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = DATE_ADD('2024-01-01', INTERVAL 1 DAY), + `datetime_value` = DATE_ADD('2024-01-01 02:03:04', INTERVAL 1 DAY), + `timestamp_value` = DATE(COALESCE('2024-01-01 02:03:04', NOW())) + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "INTERVAL '1 day'", $sql ); + $this->assertStringContainsString( "'2024-01-01 02:03:04'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for constant temporal text expressions. + */ + public function test_strict_update_avoids_temporal_helper_for_constant_temporal_text_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = REPLACE('2024/01/02', '/', '-'), + `datetime_value` = CONCAT_WS(' ', SUBSTRING('xx2024-01-02', 3), IFNULL(NULL, '03:04:05')), + `timestamp_value` = DATE_ADD(LEFT('2024-01-02 03:04:05 ignored', 19), INTERVAL 1 DAY) + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "'2024/01/02'", $sql ); + $this->assertStringContainsString( "'03:04:05'", $sql ); + $this->assertStringContainsString( "INTERVAL '1 day'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'CONCAT', $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for NULL concatenation expressions. + */ + public function test_strict_update_avoids_temporal_helper_for_null_concatenation_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = CONCAT('2024-01-02', NULL), + `datetime_value` = CONCAT_WS(NULL, '2024-01-02', '03:04:05'), + `timestamp_value` = DATE_ADD(CONCAT('2024-01-02', NULL), INTERVAL 1 DAY) + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'NULL', $sql ); + $this->assertStringContainsString( "INTERVAL '1 day'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'CONCAT', $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for NULL-producing temporal branches. + */ + public function test_strict_update_avoids_temporal_helper_for_null_temporal_branches(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE `wptests_strict_values` + SET `date_value` = IF(`id` = 1, CURRENT_DATE, NULL), + `datetime_value` = COALESCE(NULL, NOW()), + `timestamp_value` = CASE WHEN `id` = 1 THEN NOW() ELSE NULL END + WHERE `id` = 1' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'ELSE NULL END', $sql ); + $this->assertStringContainsString( 'COALESCE(NULL,', $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for functions that are guaranteed NULL. + */ + public function test_strict_update_avoids_temporal_helper_for_null_temporal_functions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + "UPDATE `wptests_strict_values` + SET `date_value` = DATE_FORMAT(NULL, CONCAT('%Y', '-%m-%d')), + `datetime_value` = FROM_UNIXTIME(NULL, CONCAT('%Y-%m-%d', ' %H:%i:%s')), + `timestamp_value` = DATE_ADD(FROM_UNIXTIME(NULL), INTERVAL 1 DAY) + WHERE `id` = 1" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( '"date_value" = NULL', $sql ); + $this->assertStringContainsString( '"datetime_value" = NULL', $sql ); + $this->assertStringContainsString( 'INTERVAL \'1 day\'', $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests strict UPDATE avoids temporal helpers for NULL casts and date arithmetic. + */ + public function test_strict_update_avoids_temporal_helper_for_null_cast_and_arithmetic_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE `wptests_strict_values` + SET `date_value` = DATE(NULL), + `datetime_value` = CAST(NULL AS DATETIME), + `timestamp_value` = DATE_ADD(NULL, INTERVAL 1 DAY) + WHERE `id` = 1' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'NULL', $sql ); + $this->assertStringContainsString( "INTERVAL '1 day'", $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests non-strict INSERT normalizes temporal boolean and zero literals using MySQL metadata. + */ + public function test_non_strict_insert_normalizes_temporal_boolean_and_zero_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, TRUE, FALSE, 0)' + ) + ); + + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'0000-00-00\', \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict UPDATE normalizes temporal boolean and zero literals using MySQL metadata. + */ + public function test_non_strict_update_normalizes_temporal_boolean_and_zero_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE `wptests_strict_values` + SET `date_value` = TRUE, + `datetime_value` = FALSE, + `timestamp_value` = 0 + WHERE `id` = 1' + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests strict integer DML rejects impossible coercions and MySQL range violations. + */ + public function test_strict_integer_literals_reject_invalid_values_and_out_of_range_values(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`, `tiny_unsigned`, `small_value`, `int_unsigned`) + VALUES (1, '3.0', TRUE, 32767, 4294967295)" + ) + ); + + $rows = $driver->query( 'SELECT int_value, tiny_unsigned, small_value, int_unsigned FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->int_value ); + $this->assertSame( '1', $rows[0]->tiny_unsigned ); + $this->assertSame( '32767', $rows[0]->small_value ); + $this->assertSame( '4294967295', $rows[0]->int_unsigned ); + + $cases = array( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (2, 'abc')" => "Incorrect integer value: 'abc'", + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (2, '12abc')" => "Incorrect integer value: '12abc'", + 'INSERT INTO `wptests_strict_ints` (`id`, `tiny_unsigned`) VALUES (2, -1)' => "Out of range value: '-1'", + 'INSERT INTO `wptests_strict_ints` (`id`, `tiny_unsigned`) VALUES (2, 256)' => "Out of range value: '256'", + 'UPDATE `wptests_strict_ints` SET `small_value` = 32768 WHERE `id` = 1' => "Out of range value: '32768'", + 'UPDATE `wptests_strict_ints` SET `int_unsigned` = -1 WHERE `id` = 1' => "Out of range value: '-1'", + ); + + foreach ( $cases as $query => $message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected invalid integer value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests strict text DML rejects truncation using MySQL metadata. + */ + public function test_strict_text_literals_reject_truncation_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_text_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_strict_texts` (`id`, `varchar_value`, `char_value`, `tinytext_value`) VALUES (1, 'abc', 'xyz', 'short')" ) ); + + $long_tinytext = str_repeat( 'x', 256 ); + $cases = array( + "INSERT INTO `wptests_strict_texts` (`id`, `varchar_value`) VALUES (2, 'abcd')" => "Data too long for column 'varchar_value'", + "UPDATE `wptests_strict_texts` SET `char_value` = 'abcd' WHERE `id` = 1" => "Data too long for column 'char_value'", + "INSERT INTO `wptests_strict_texts` (`id`, `tinytext_value`) VALUES (2, '{$long_tinytext}')" => "Data too long for column 'tinytext_value'", + ); + + foreach ( $cases as $query => $message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected text truncation to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests strict SQL mode leaves omitted NOT NULL INSERT columns to fail visibly. + */ + public function test_strict_insert_does_not_append_omitted_not_null_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 simple WordPress REPLACE statements delete then insert known conflicts. + */ + public function test_simple_wordpress_replace_with_existing_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", display_name) VALUES (2, \'Walter Sobchak\')' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Walter Replace Sobchak')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( $replace, $driver->get_last_mysql_query() ); + + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_users" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_users WHERE `ID` = 2' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); + } + + /** + * Tests singular VALUE row-list REPLACE syntax is translated like VALUES. + */ + public function test_value_keyword_replace_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_value_keyword ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_value_keyword ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_value_keyword` (`ID`, `display_name`) VALUE (2, 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_value_keyword" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_replace_value_keyword" ("ID", "display_name") VALUES (2, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_value_keyword WHERE `ID` = 2' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'new', $rows[0]->display_name ); + } + + /** + * Tests REPLACE accepts MySQL's optional INTO keyword. + */ + public function test_simple_replace_without_into_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_without_into ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_without_into ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE `wptests_replace_without_into` (`ID`, `display_name`) VALUES (2, 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_without_into" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_replace_without_into" ("ID", "display_name") VALUES (2, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_without_into WHERE `ID` = 2' ); + $this->assertSame( 'new', $rows[0]->display_name ); + } + + /** + * Tests REPLACE ... SET statements delete then insert known conflicts and keep MySQL row counts. + */ + public function test_replace_set_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_id INTEGER PRIMARY KEY, + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_id, option_name, option_value) VALUES (1, 'siteurl', 'old')" ); + + $replace = "REPLACE INTO `wptests_options` SET `option_id` = 8, `option_name` = 'siteurl', `option_value` = 'updated'"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE (("option_id" = 8) OR ("option_name" = \'siteurl\'))', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (8, \'siteurl\', \'updated\')', + ) + ); + + $rows = $driver->query( "SELECT option_id, option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '8', $rows[0]->option_id ); + $this->assertSame( 'updated', $rows[0]->option_value ); + + $replace = "REPLACE `wptests_options` SET `option_id` = 9, `option_name` = 'home', `option_value` = 'created'"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE (("option_id" = 9) OR ("option_name" = \'home\'))', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (9, \'home\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT option_name, option_value FROM wptests_options ORDER BY option_id' ); + $this->assertCount( 2, $rows ); + $this->assertSame( 'siteurl', $rows[0]->option_name ); + $this->assertSame( 'updated', $rows[0]->option_value ); + $this->assertSame( 'home', $rows[1]->option_name ); + $this->assertSame( 'created', $rows[1]->option_value ); + + $qualified_replace = "REPLACE INTO `wptests_options` SET `wptests_options`.`option_id` = 10, `wptests_options`.`option_name` = 'home', `wptests`.`wptests_options`.`option_value` = 'qualified'"; + + $this->assertSame( 2, $driver->query( $qualified_replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE (("option_id" = 10) OR ("option_name" = \'home\'))', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (10, \'home\', \'qualified\')', + ) + ); + + $rows = $driver->query( "SELECT option_id, option_value FROM wptests_options WHERE option_name = 'home'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '10', $rows[0]->option_id ); + $this->assertSame( 'qualified', $rows[0]->option_value ); + } + + /** + * Tests REPLACE priority modifiers are no-ops for SET and SELECT forms. + */ + public function test_replace_priority_modifiers_apply_to_set_and_select_forms(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_priority ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_priority_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_replace_priority (id, value) VALUES (1, 'old')" ); + $driver->query( "INSERT INTO wptests_replace_priority_source (id, value) VALUES (1, 'selected'), (2, 'new')" ); + + $replace_set = "REPLACE LOW_PRIORITY INTO wptests_replace_priority SET id = 1, value = 'set'"; + + $this->assertSame( 2, $driver->query( $replace_set ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_priority" WHERE ("id" = 1)', + 'INSERT INTO "wptests_replace_priority" ("id", "value") VALUES (1, \'set\')', + ) + ); + + $replace_select = 'REPLACE DELAYED INTO wptests_replace_priority (id, value) + SELECT id, value FROM wptests_replace_priority_source'; + + $this->assertSame( 3, $driver->query( $replace_select ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_priority' ); + $this->assertStringContainsString( + ' AS SELECT id AS "id" , value AS "value" FROM wptests_replace_priority_source', + $sql[1] + ); + $this->assertStringNotContainsString( 'DELAYED', implode( "\n", $sql ) ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_replace_priority ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'selected', + ), + (object) array( + 'id' => '2', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests multi-row REPLACE statements delete then insert known conflicts and keep MySQL row counts. + */ + public function test_multi_row_replace_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_multi ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_multi ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_multi` (`ID`, `display_name`) VALUES (2, 'updated'), (3, 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi" WHERE ("ID" = 2) OR ("ID" = 3)', + 'INSERT INTO "wptests_replace_multi" ("ID", "display_name") VALUES (2, \'updated\'), (3, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_multi ORDER BY `ID`' ); + $this->assertEquals( + array( + (object) array( + 'ID' => '2', + 'display_name' => 'updated', + ), + (object) array( + 'ID' => '3', + 'display_name' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses MySQL unique-key metadata beyond WordPress heuristics. + */ + public function test_replace_uses_metadata_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_unique_slug ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_unique_slug (id, slug, value) VALUES (1, 'same', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_unique_slug (id, slug, value) VALUES (2, 'same', 'new'), (3, 'other', 'created')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_unique_slug" WHERE (("id" = 2) OR ("slug" = \'same\')) OR (("id" = 3) OR ("slug" = \'other\'))', + 'INSERT INTO "wptests_replace_unique_slug" ("id", "slug", "value") VALUES (2, \'same\', \'new\'), (3, \'other\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_unique_slug ORDER BY slug' ); + $this->assertEquals( + array( + (object) array( + 'id' => '3', + 'slug' => 'other', + 'value' => 'created', + ), + (object) array( + 'id' => '2', + 'slug' => 'same', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests deterministic REPLACE ... VALUES deletes rows matching any unique key. + */ + public function test_replace_values_deletes_conflicts_across_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_multi_unique_values ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (1, 'old-slug', 'old-id')" ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (2, 'shared-slug', 'old-slug')" ); + + $replace = "REPLACE INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (1, 'shared-slug', 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi_unique_values" WHERE (("id" = 1) OR ("slug" = \'shared-slug\'))', + 'INSERT INTO "wptests_replace_multi_unique_values" ("id", "slug", "value") VALUES (1, \'shared-slug\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_multi_unique_values' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared-slug', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE VALUES materializes expression-backed conflicts across unique keys. + */ + public function test_multi_row_replace_with_expression_conflict_key_deletes_all_unique_conflicts(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_expr_multi ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_expr_multi (id, slug, value) VALUES (1, 'one', 'old-one')" ); + $driver->query( "INSERT INTO wptests_replace_expr_multi (id, slug, value) VALUES (2, 'two', 'old-two')" ); + + $replace = "REPLACE INTO wptests_replace_expr_multi (id, slug, value) + VALUES (1, (SELECT 'two'), 'new'), (3, 'three', 'fresh')"; + + $this->assertSame( 4, $driver->query( $replace ) ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 5, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_values_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_values_[a-f0-9]{12}" AS SELECT 1 AS "id", \\(SELECT \'two\'\\) AS "slug", \'new\' AS "value" UNION ALL SELECT 3, \'three\', \'fresh\'$/', $sql[1] ); + $this->assertStringContainsString( 'DELETE FROM "wptests_replace_expr_multi"', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id"', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug"', $sql[2] ); + $this->assertStringContainsString( 'INSERT INTO "wptests_replace_expr_multi"', $sql[3] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_values_[a-f0-9]{12}"$/', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_expr_multi ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'two', + 'value' => 'new', + ), + (object) array( + 'id' => '3', + 'slug' => 'three', + 'value' => 'fresh', + ), + ), + $rows + ); + } + + /** + * Tests deterministic REPLACE ... SET deletes rows matching any unique key. + */ + public function test_replace_set_deletes_conflicts_across_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_multi_unique_set ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_set (id, slug, value) VALUES (1, 'old-slug', 'old-id')" ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_set (id, slug, value) VALUES (2, 'shared-slug', 'old-slug')" ); + + $replace = "REPLACE INTO wptests_replace_multi_unique_set SET id = 1, slug = 'shared-slug', value = 'new'"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi_unique_set" WHERE (("id" = 1) OR ("slug" = \'shared-slug\'))', + 'INSERT INTO "wptests_replace_multi_unique_set" ("id", "slug", "value") VALUES (1, \'shared-slug\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_multi_unique_set' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared-slug', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses composite unique-key metadata. + */ + public function test_replace_uses_metadata_composite_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_composite_unique ( + site_id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + UNIQUE KEY site_slug (site_id, slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_composite_unique (site_id, slug, value) VALUES (1, 'same', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_composite_unique (site_id, slug, value) VALUES (1, 'same', 'new'), (2, 'same', 'created')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_composite_unique" WHERE ("site_id" = 1 AND "slug" = \'same\') OR ("site_id" = 2 AND "slug" = \'same\')', + 'INSERT INTO "wptests_replace_composite_unique" ("site_id", "slug", "value") VALUES (1, \'same\', \'new\'), (2, \'same\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT site_id, slug, value FROM wptests_replace_composite_unique ORDER BY site_id' ); + $this->assertEquals( + array( + (object) array( + 'site_id' => '1', + 'slug' => 'same', + 'value' => 'new', + ), + (object) array( + 'site_id' => '2', + 'slug' => 'same', + 'value' => 'created', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses prefix unique-key metadata. + */ + public function test_replace_uses_metadata_prefix_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_prefix_unique ( + slug varchar(255) NOT NULL, + value text NOT NULL, + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_prefix_unique (slug, value) VALUES ('existing-slug-one', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_prefix_unique (slug, value) VALUES ('existing-slug-two', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_prefix_unique" WHERE (SUBSTR(CAST("slug" AS text), 1, 10) = SUBSTR(CAST(\'existing-slug-two\' AS text), 1, 10))', + 'INSERT INTO "wptests_replace_prefix_unique" ("slug", "value") VALUES (\'existing-slug-two\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_replace_prefix_unique' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'existing-slug-two', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests REPLACE ... SELECT statements use delete-then-insert and MySQL row counts. + */ + public function test_replace_select_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select (id INTEGER PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_source (id INTEGER NOT NULL, name TEXT NOT NULL, color TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select (id, name, color) VALUES (1, 'old', 'red')" ); + $driver->query( "INSERT INTO wptests_replace_select_source (id, name, color) VALUES (1, 'updated', 'blue'), (2, 'new', 'green')" ); + + $replace = 'REPLACE INTO wptests_replace_select (`id`, `name`, `color`) + SELECT id, name, color FROM wptests_replace_select_source WHERE 1 = 1'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select' ); + $this->assertStringContainsString( + ' AS SELECT id AS "id" , CAST(name AS text) AS "name" , color AS "color" FROM wptests_replace_select_source WHERE 1 = 1', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id")', + $sql[2] + ); + $this->assertStringContainsString( + '("id", "name", "color") SELECT "__wp_pg_replace_rows"."id", "__wp_pg_replace_rows"."name", "__wp_pg_replace_rows"."color"', + $sql[3] + ); + + $rows = $driver->query( 'SELECT id, name, color FROM wptests_replace_select ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => 'updated', + 'color' => 'blue', + ), + (object) array( + 'id' => '2', + 'name' => 'new', + 'color' => 'green', + ), + ), + $rows + ); + } + + /** + * Tests literal REPLACE ... SELECT statements resolve ambiguous targets from source rows. + */ + public function test_replace_select_uses_conflicting_unique_key_for_literal_select(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $replace = "REPLACE INTO `ambiguous_upsert` + SELECT 2, 'existing', 'new' FROM DUAL"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'ambiguous_upsert' ); + $this->assertStringContainsString( + ' AS SELECT 2 AS "id" , \'existing\' AS "slug" , \'new\' AS "value"', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'slug' => 'existing', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests non-strict REPLACE ... SELECT fills omitted NOT NULL defaults from MySQL metadata. + */ + public function test_non_strict_replace_select_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_replace_select_defaults ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 999, + color TEXT + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_select_defaults ( + id int NOT NULL, + name text NOT NULL, + size int NOT NULL DEFAULT 999, + color text, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_select_defaults_source ( + id INTEGER NOT NULL, + size INTEGER NOT NULL, + color TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_replace_select_defaults (id, name, size, color) VALUES (1, 'old', 10, 'red')" ); + $driver->query( "INSERT INTO wptests_replace_select_defaults_source (id, size, color) VALUES (1, 123, 'blue'), (2, 456, 'green')" ); + + $replace = 'REPLACE INTO wptests_replace_select_defaults (`color`, `id`, `size`) + SELECT color, id, size FROM wptests_replace_select_defaults_source'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_defaults' ); + $postgresql_sql = implode( "\n", $sql ); + $this->assertStringContainsString( + 'INSERT INTO "wptests_replace_select_defaults" ("color", "id", "size", "name")', + $postgresql_sql + ); + $this->assertStringContainsString( + 'SELECT "__wp_pg_replace_rows_source".*, \'\' AS "name" FROM (SELECT', + $postgresql_sql + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id")', + $postgresql_sql + ); + + $rows = $driver->query( 'SELECT id, name, size, color FROM wptests_replace_select_defaults ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => '', + 'size' => '123', + 'color' => 'blue', + ), + (object) array( + 'id' => '2', + 'name' => '', + 'size' => '456', + 'color' => 'green', + ), + ), + $rows + ); + } + + /** + * Tests columnless REPLACE ... SELECT infers target columns from MySQL metadata. + */ + public function test_columnless_replace_select_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select_columnless ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_select_columnless ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + display_name varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_columnless_source ("ID" INTEGER NOT NULL, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_select_columnless ("ID", display_name) VALUES (2, \'old\')' ); + $driver->query( 'INSERT INTO wptests_replace_select_columnless_source ("ID", display_name) VALUES (2, \'updated\'), (3, \'new\')' ); + + $replace = 'REPLACE INTO wptests_replace_select_columnless + SELECT `ID`, display_name FROM wptests_replace_select_columnless_source WHERE 1 = 1'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_columnless' ); + $this->assertStringContainsString( + ' AS SELECT "ID" AS "ID" , display_name AS "display_name" FROM wptests_replace_select_columnless_source WHERE 1 = 1', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."ID" = "__wp_pg_replace_rows"."ID")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_select_columnless ORDER BY `ID`' ); + $this->assertSame( 'updated', $rows[0]->display_name ); + $this->assertSame( 'new', $rows[1]->display_name ); + } + + /** + * Tests REPLACE ... SELECT deletes old rows and inserts omitted defaults. + */ + public function test_replace_select_known_unique_conflict_fires_delete_trigger_and_inserts_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_select_observable ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + note TEXT NOT NULL DEFAULT \'default-note\' + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_select_observable ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + note text NOT NULL DEFAULT \'default-note\', + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_source_observable (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_delete_log (old_id INTEGER NOT NULL, old_slug TEXT NOT NULL)' ); + $driver->get_connection()->query( + 'CREATE FUNCTION wptests_replace_select_observable_deleted_fn() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO wptests_replace_select_delete_log (old_id, old_slug) VALUES (OLD.id, OLD.slug); + RETURN OLD; + END + $$' + ); + $driver->get_connection()->query( + 'CREATE TRIGGER wptests_replace_select_observable_deleted + AFTER DELETE ON wptests_replace_select_observable + FOR EACH ROW EXECUTE FUNCTION wptests_replace_select_observable_deleted_fn()' + ); + $driver->query( "INSERT INTO wptests_replace_select_observable (id, slug, value, note) VALUES (1, 'same', 'old', 'custom-note')" ); + $driver->query( "INSERT INTO wptests_replace_select_source_observable (id, slug, value) VALUES (2, 'same', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_observable (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_source_observable'; + + $this->assertSame( 2, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_observable' ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT id, slug, value, note FROM wptests_replace_select_observable' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'same', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + $this->assertSame( 'default-note', $rows[0]->note ); + + $log_rows = $driver->query( 'SELECT old_id, old_slug FROM wptests_replace_select_delete_log' ); + $this->assertCount( 1, $log_rows ); + $this->assertSame( '1', $log_rows[0]->old_id ); + $this->assertSame( 'same', $log_rows[0]->old_slug ); + } + + /** + * Tests REPLACE ... SELECT observes ON DELETE CASCADE. + */ + public function test_replace_select_known_unique_conflict_observes_delete_cascade(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_select_cascade_parent ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_select_cascade_parent ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_cascade_source (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( + 'CREATE TABLE wptests_replace_select_cascade_child ( + parent_id INTEGER NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES wptests_replace_select_cascade_parent (id) ON DELETE CASCADE + )' + ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_parent (id, slug, value) VALUES (1, 'same', 'old')" ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_child (parent_id, value) VALUES (1, 'child')" ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_source (id, slug, value) VALUES (2, 'same', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_cascade_parent (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_cascade_source'; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_cascade_parent' ); + + $parents = $driver->query( 'SELECT id, slug, value FROM wptests_replace_select_cascade_parent' ); + $this->assertCount( 1, $parents ); + $this->assertSame( '2', $parents[0]->id ); + $this->assertSame( 'same', $parents[0]->slug ); + $this->assertSame( 'new', $parents[0]->value ); + + $children = $driver->query( 'SELECT parent_id, value FROM wptests_replace_select_cascade_child' ); + $this->assertSame( array(), $children ); + } + + /** + * Tests duplicate REPLACE ... SELECT source keys replay with MySQL row-by-row semantics. + */ + public function test_replace_select_with_duplicate_source_conflict_keys_replays_rows_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select_duplicate (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_duplicate_source (id INTEGER NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select_duplicate_source (id, value) VALUES (1, 'first'), (1, 'second')" ); + + $this->assertSame( + 3, + $driver->query( + 'REPLACE INTO wptests_replace_select_duplicate (id, value) + SELECT id, value FROM wptests_replace_select_duplicate_source' + ) + ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 9, $sql ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_replace_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 1', $sql[4] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 2', $sql[5] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 2', $sql[6] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_select_ord_[a-f0-9]{12}"$/', $sql[7] ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_replace_select_duplicate' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'second', $rows[0]->value ); + } + + /** + * Tests REPLACE ... SELECT counts every old row deleted by unique-key conflicts. + */ + public function test_replace_select_counts_multiple_deleted_unique_conflicts(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_select_multi_conflict ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_select_multi_conflict ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_multi_conflict_source (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select_multi_conflict (id, slug, value) VALUES (1, 'one', 'old-one'), (2, 'two', 'old-two')" ); + $driver->query( "INSERT INTO wptests_replace_select_multi_conflict_source (id, slug, value) VALUES (1, 'two', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_multi_conflict (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_multi_conflict_source'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_multi_conflict' ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_select_multi_conflict' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'two', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests columnless multi-row REPLACE statements infer target columns from MySQL metadata. + */ + public function test_columnless_multi_row_replace_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_columnless ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_columnless ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + display_name varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $driver->query( 'INSERT INTO wptests_replace_columnless ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_columnless` VALUES (2, 'updated'), (3, 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_columnless" WHERE ("ID" = 2) OR ("ID" = 3)', + 'INSERT INTO "wptests_replace_columnless" ("ID", "display_name") VALUES (2, \'updated\'), (3, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_columnless ORDER BY `ID`' ); + $this->assertSame( 'updated', $rows[0]->display_name ); + $this->assertSame( 'new', $rows[1]->display_name ); + } + + /** + * Tests duplicate conflict keys in one REPLACE batch run sequentially. + */ + public function test_multi_row_replace_with_duplicate_conflict_values_runs_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_duplicate ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_replace_duplicate` (`ID`, `display_name`) VALUES (4, 'first'), (4, 'second')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_duplicate" WHERE "ID" = 4', + 'INSERT INTO "wptests_replace_duplicate" ("ID", "display_name") VALUES (4, \'first\')', + 'DELETE FROM "wptests_replace_duplicate" WHERE "ID" = 4', + 'INSERT INTO "wptests_replace_duplicate" ("ID", "display_name") VALUES (4, \'second\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_duplicate WHERE `ID` = 4' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'second', $rows[0]->display_name ); + } + + /** + * Tests multi-row REPLACE without a known conflict column falls back to INSERT. + */ + public function test_multi_row_replace_without_known_conflict_column_is_inserted(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_multi_plain (post_name TEXT NOT NULL, post_status TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_replace_multi_plain` (`post_name`, `post_status`) VALUES ('hello-world', 'publish'), ('about', 'draft')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( + 'INSERT INTO "wptests_replace_multi_plain" ("post_name", "post_status") VALUES (\'hello-world\', \'publish\'), (\'about\', \'draft\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT post_name, post_status FROM wptests_replace_multi_plain ORDER BY post_name' ); + $this->assertEquals( + array( + (object) array( + 'post_name' => 'about', + 'post_status' => 'draft', + ), + (object) array( + 'post_name' => 'hello-world', + 'post_status' => 'publish', + ), + ), + $rows + ); + } + + /** + * Tests WooCommerce customer lookup REPLACE statements use customer_id conflicts. + */ + public function test_simple_wordpress_replace_with_customer_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_wc_customer_lookup ( + customer_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + email TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_wc_customer_lookup (customer_id, user_id, email) VALUES (1, 1, 'old@example.com')" ); + + $replace = "REPLACE INTO `wptests_wc_customer_lookup` (`user_id`, `email`, `customer_id`) VALUES (2, 'new@example.com', '1')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_wc_customer_lookup" WHERE ("customer_id" = \'1\')', + 'INSERT INTO "wptests_wc_customer_lookup" ("user_id", "email", "customer_id") VALUES (2, \'new@example.com\', \'1\')', + ) + ); + + $rows = $driver->query( 'SELECT user_id, email FROM wptests_wc_customer_lookup WHERE customer_id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->user_id ); + $this->assertSame( 'new@example.com', $rows[0]->email ); + } + + /** + * Tests WooCommerce product lookup REPLACE statements coerce empty decimal strings. + */ + public function test_woocommerce_product_lookup_replace_coerces_empty_decimal_strings(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_wc_product_meta_lookup_prices ( + `product_id` bigint(20) unsigned NOT NULL, + `min_price` decimal(19,4) DEFAULT NULL, + `max_price` decimal(19,4) DEFAULT NULL, + `average_rating` decimal(3,2) NOT NULL DEFAULT 0, + PRIMARY KEY (`product_id`) + )' + ); + + $replace = "REPLACE INTO `wptests_wc_product_meta_lookup_prices` (`product_id`, `min_price`, `max_price`, `average_rating`) VALUES (109, '', '', '4.50')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringContainsString( $this->get_expected_mysql_numeric_cast_sql( "''" ), $queries[1]['sql'] ); + + $rows = $driver->query( 'SELECT min_price, max_price, average_rating FROM wptests_wc_product_meta_lookup_prices WHERE product_id = 109' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 0.0, (float) $rows[0]->min_price ); + $this->assertSame( 0.0, (float) $rows[0]->max_price ); + $this->assertSame( 4.5, (float) $rows[0]->average_rating ); + } + + /** + * Tests non-strict REPLACE applies omitted NOT NULL defaults on insert and conflict paths. + */ + public function test_non_strict_replace_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_options_table_with_mysql_metadata( $driver ); + + $replace = "REPLACE INTO `wptests_options` (`option_name`) VALUES ('siteurl')"; + $expected_statements = array( + 'DELETE FROM "wptests_options" WHERE ("option_name" = \'siteurl\')', + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'\', \'yes\')', + ); + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( $driver, $expected_statements ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + + $driver->query( "UPDATE wptests_options SET option_value = 'custom', autoload = 'no' WHERE option_name = 'siteurl'" ); + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( $driver, $expected_statements ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests known-target REPLACE deletes old rows and inserts omitted defaults. + */ + public function test_replace_known_unique_conflict_fires_delete_trigger_and_inserts_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_observable ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + note TEXT NOT NULL DEFAULT \'default-note\' + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_observable ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + note text NOT NULL DEFAULT \'default-note\', + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_delete_log (old_id INTEGER NOT NULL, old_slug TEXT NOT NULL)' ); + $driver->get_connection()->query( + 'CREATE FUNCTION wptests_replace_observable_deleted_fn() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO wptests_replace_delete_log (old_id, old_slug) VALUES (OLD.id, OLD.slug); + RETURN OLD; + END + $$' + ); + $driver->get_connection()->query( + 'CREATE TRIGGER wptests_replace_observable_deleted + AFTER DELETE ON wptests_replace_observable + FOR EACH ROW EXECUTE FUNCTION wptests_replace_observable_deleted_fn()' + ); + $driver->query( "INSERT INTO wptests_replace_observable (id, slug, value, note) VALUES (1, 'same', 'old', 'custom-note')" ); + + $replace = "REPLACE INTO wptests_replace_observable (id, slug, value) VALUES (2, 'same', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_observable" WHERE (("id" = 2) OR ("slug" = \'same\'))', + 'INSERT INTO "wptests_replace_observable" ("id", "slug", "value") VALUES (2, \'same\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value, note FROM wptests_replace_observable' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'same', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + $this->assertSame( 'default-note', $rows[0]->note ); + + $log_rows = $driver->query( 'SELECT old_id, old_slug FROM wptests_replace_delete_log' ); + $this->assertCount( 1, $log_rows ); + $this->assertSame( '1', $log_rows[0]->old_id ); + $this->assertSame( 'same', $log_rows[0]->old_slug ); + } + + /** + * Tests known-target REPLACE observes ON DELETE CASCADE. + */ + public function test_replace_known_unique_conflict_observes_delete_cascade(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_cascade_parent ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_replace_cascade_parent ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_cascade_child ( + parent_id INTEGER NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES wptests_replace_cascade_parent (id) ON DELETE CASCADE + )' + ); + $driver->query( "INSERT INTO wptests_replace_cascade_parent (id, slug, value) VALUES (1, 'same', 'old')" ); + $driver->query( "INSERT INTO wptests_replace_cascade_child (parent_id, value) VALUES (1, 'child')" ); + + $replace = "REPLACE INTO wptests_replace_cascade_parent (id, slug, value) VALUES (2, 'same', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_cascade_parent" WHERE (("id" = 2) OR ("slug" = \'same\'))', + 'INSERT INTO "wptests_replace_cascade_parent" ("id", "slug", "value") VALUES (2, \'same\', \'new\')', + ) + ); + + $parents = $driver->query( 'SELECT id, slug, value FROM wptests_replace_cascade_parent' ); + $this->assertCount( 1, $parents ); + $this->assertSame( '2', $parents[0]->id ); + $this->assertSame( 'same', $parents[0]->slug ); + $this->assertSame( 'new', $parents[0]->value ); + + $children = $driver->query( 'SELECT parent_id, value FROM wptests_replace_cascade_child' ); + $this->assertSame( array(), $children ); + } + + /** + * Tests simple REPLACE without a known conflict column falls back to INSERT. + */ + public function test_simple_wordpress_replace_without_known_conflict_column_is_inserted(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (post_name TEXT NOT NULL, post_status TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-world', 'publish')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-world\', \'publish\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $low_priority_replace = "REPLACE LOW_PRIORITY INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-low', 'publish')"; + + $this->assertSame( 1, $driver->query( $low_priority_replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-low\', \'publish\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $delayed_replace = "REPLACE DELAYED INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-delayed', 'draft')"; + + $this->assertSame( 1, $driver->query( $delayed_replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-delayed\', \'draft\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests unsupported REPLACE shapes fail before backend execution. + */ + public function test_unsupported_replace_shapes_fail_closed_before_backend(): void { + $queries = array( + "REPLACE INTO wptests_posts SET post_name = 'hello-world', post_name = 'duplicate'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported REPLACE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported REPLACE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests 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(), + ), + array( + 'sql' => "COMMENT ON COLUMN \"temp\".\"wptests_charset_temp\".\"a\" IS '__wp_mysql_column_charset:YmlnNQ==\n__wp_mysql_column_collation:YmlnNV9jaGluZXNlX2Np'", + 'params' => array(), + ), + array( + 'sql' => "COMMENT ON COLUMN \"temp\".\"wptests_charset_temp\".\"b\" IS '__wp_mysql_column_charset:YmlnNQ==\n__wp_mysql_column_collation:YmlnNV9jaGluZXNlX2Np'", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests temporary table creation with MySQL binary/blob types is translated. + */ + public function test_create_temporary_table_with_binary_blob_types_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + $query = 'CREATE TEMPORARY TABLE wptests_binary_temp ( b BINARY, vb VARBINARY(16), tb TINYBLOB, bl BLOB, mb MEDIUMBLOB, lb LONGBLOB )'; + + $this->assertSame( 0, $driver->query( $query ) ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringStartsWith( 'CREATE TEMPORARY TABLE "wptests_binary_temp"', $sql ); + $this->assertStringContainsString( '"b" __wp_mysql_binary_1', $sql ); + $this->assertStringContainsString( '"vb" __wp_mysql_varbinary_16', $sql ); + $this->assertStringContainsString( '"tb" __wp_mysql_tinyblob', $sql ); + $this->assertStringContainsString( '"bl" __wp_mysql_blob', $sql ); + $this->assertStringContainsString( '"mb" __wp_mysql_mediumblob', $sql ); + $this->assertStringContainsString( '"lb" __wp_mysql_longblob', $sql ); + } + + /** + * Tests unqualified DROP TABLE removes active temporary metadata before permanent metadata. + */ /** + * Tests CREATE TEMPORARY TABLE IF NOT EXISTS follows MySQL duplicate-table behavior. + */ + public function test_create_temporary_table_if_not_exists_keeps_existing_table(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_if_not_exists (id INTEGER, name TEXT)' ) ); + $this->assertSame( 0, $driver->query( 'CREATE TEMPORARY TABLE IF NOT EXISTS wptests_temp_if_not_exists (id INTEGER, name TEXT)' ) ); + + try { + $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_if_not_exists (id INTEGER, name TEXT)' ); + $this->fail( 'Expected duplicate temporary CREATE TABLE to fail.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + } + + /** + * Tests FULLTEXT search syntax fails before reaching PostgreSQL. + */ + public function test_fulltext_search_syntax_fails_closed_before_backend_execution(): void { + $queries = array( + "SELECT MATCH(body) AGAINST ('needle') AS score FROM wptests_search_geo", + "SELECT * FROM wptests_search_geo WHERE MATCH(body) AGAINST ('needle' IN BOOLEAN MODE)", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported FULLTEXT search syntax to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL full-text search syntax.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests install-translated quoted CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ /** + * Tests schema-qualified install-translated CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ /** + * Tests internal MySQL index metadata quotes its PostgreSQL-reserved collation identifier. + */ /** + * 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( 'DROP TABLE IF EXISTS "public"."wptests_public_index"' ); + try { + $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 public.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'] + ); + } finally { + $pdo->exec( 'DROP TABLE IF EXISTS "public"."wptests_public_index"' ); + } + } + + /** + * 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)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + '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. + */ /** + * Tests standalone DROP INDEX PRIMARY removes the primary-key constraint metadata. + */ /** + * Tests ALTER TABLE DROP INDEX removes PostgreSQL schema and MySQL metadata. + */ /** + * Tests ALTER TABLE DROP INDEX accepts backticked identifiers. + */ /** + * Tests ALTER TABLE DROP KEY removes PostgreSQL schema and MySQL metadata. + */ /** + * Tests ALTER TABLE ADD primary and unique constraints update backend and MySQL metadata. + */ /** + * Tests ALTER TABLE DROP primary-key forms update backend and MySQL metadata. + */ /** + * Tests ALTER TABLE DROP CONSTRAINT fails before backend execution when metadata has no matching constraint. + */ /** + * Tests ALTER TABLE DROP CONSTRAINT fails closed when metadata has multiple matching constraint classes. + */ /** + * Tests ALTER TABLE ADD/DROP CHECK forms translate to PostgreSQL constraints. + */ /** + * Tests unsupported ALTER TABLE ADD CHECK JSON_VALID() forms fail before backend execution. + */ /** + * Tests ALTER TABLE DROP CHECK fails before backend execution when metadata has no matching constraint. + */ /** + * Tests main database-qualified standalone index statements target public table metadata. + */ /** + * Tests main database-qualified DROP TABLE removes tables and MySQL metadata. + */ /** + * Tests DROP TABLE accepts MySQL RESTRICT/CASCADE suffixes as no-ops. + */ /** + * Tests unsupported standalone index DDL fails before backend execution. + */ + public function test_standalone_index_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'CREATE INDEX idx_value ON wptests_index_fail (value) KEY_BLOCK_SIZE=bad', + 'CREATE INDEX idx_value ON wptests_index_fail (value) ALGORITHM=INSTANT', + 'CREATE INDEX idx_value ON wptests_index_fail (value) LOCK=UNKNOWN', + 'CREATE INDEX idx_value ON wptests_index_fail (value) ENGINE_ATTRIBUTE="{}"', + 'CREATE INDEX idx_value ON wptests_index_fail (value) WITH PARSER ngram', + 'CREATE FULLTEXT INDEX idx_value ON wptests_index_fail (value) COMMENT "Lookup"', + 'CREATE SPATIAL INDEX idx_value ON wptests_index_fail (value) KEY_BLOCK_SIZE=8', + 'CREATE INDEX IF NOT EXISTS "wptests_index_fail__" ON "wptests_index_fail" ("value")', + 'CREATE INDEX idx_value ON information_schema.tables (name)', + 'CREATE INDEX idx_value ON other_db.wptests_index_fail (value)', + 'DROP INDEX idx_value ON wptests_index_fail KEY_BLOCK_SIZE=8', + 'DROP INDEX idx_value ON wptests_index_fail ALGORITHM=INSTANT', + 'DROP INDEX idx_value ON wptests_index_fail LOCK=UNKNOWN', + 'DROP INDEX idx_value ON information_schema.tables', + 'DROP INDEX idx_value ON other_db.wptests_index_fail', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_index_fail (value varchar(255) NOT NULL)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported standalone index DDL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertContains( + $e->getMessage(), + array( + 'Unsupported CREATE INDEX statement.', + 'Unsupported DROP INDEX statement.', + 'Unsupported information_schema query.', + ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported json_valid() CHECK forms fail before backend execution. + */ + public function test_create_table_unsupported_json_valid_check_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CREATE TABLE wptests_json_check_unsupported (data JSON CHECK (json_valid(data, data)))' ); + $this->fail( 'Expected unsupported CHECK constraint expression to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CHECK constraint expression.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests backticked SELECT identifiers are translated to PostgreSQL quoting. + */ + public function test_simple_select_with_backticked_identifiers_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, "post_title" TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", "post_title") VALUES (1, \'Hello\')' ); + + $select = 'SELECT `ID`, `post_title` FROM `wptests_posts` WHERE `ID` = 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'Hello', $rows[0]->post_title ); + $this->assertSame( $select, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests bare uppercase ID SELECT identifiers are quoted for PostgreSQL. + */ + public function test_simple_select_with_bare_uppercase_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + + $select = 'SELECT ID, user_login FROM wptests_users WHERE ID = 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'admin', $rows[0]->user_login ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID", user_login FROM wptests_users WHERE "ID" = 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests a simple SELECT with a trailing LIMIT translates uppercase WHERE identifiers. + */ + public function test_simple_select_with_bare_uppercase_id_where_and_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_title TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_title) VALUES (1, \'Hello\')' ); + + $select = 'SELECT * FROM wptests_posts WHERE ID = 1 LIMIT 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT * FROM wptests_posts WHERE "ID" = 1 LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests COUNT projections translate uppercase aggregate identifiers. + */ + public function test_simple_select_count_with_bare_uppercase_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + + $select = 'SELECT COUNT(ID) as c FROM wptests_users'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->c ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT COUNT("ID") as c FROM wptests_users', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests mixed-case comment SELECT identifiers are quoted for PostgreSQL. + */ + public function test_simple_select_with_mixed_case_comment_identifiers_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID") VALUES (7, 1)' ); + + $select = 'SELECT comment_ID FROM wptests_comments WHERE comment_post_ID = 1 ORDER BY comment_ID DESC'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->comment_ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "comment_ID" FROM wptests_comments WHERE "comment_post_ID" = 1 ORDER BY "comment_ID" DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests approved comments ordered by GMT date use comment_ID as a tie-breaker. + */ + public function test_simple_select_approved_comments_order_uses_comment_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (184, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (180, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (181, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (183, 8, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (185, 7, \'2024-01-01 00:00:00\', \'0\')' ); + + $select = "SELECT * + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '180', '181', '184' ), + array_map( + static function ( $row ) { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT * FROM wptests_comments WHERE "comment_post_ID" = 7 AND comment_approved = \'1\' ORDER BY wptests_comments.comment_date_gmt ASC, "comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests approved comment ID lookups ordered by GMT date use comment_ID as a tie-breaker. + */ + public function test_simple_select_approved_comment_ids_order_uses_comment_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (184, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (180, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (181, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (183, 8, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (185, 7, \'2024-01-01 00:00:00\', \'0\')' ); + + $select = "SELECT wptests_comments.comment_ID + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '180', '181', '184' ), + array_map( + static function ( $row ) { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments WHERE "comment_post_ID" = 7 AND comment_approved = \'1\' ORDER BY wptests_comments.comment_date_gmt ASC, "comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests 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->remove_real_pgsql_test_schema_qualifiers( + $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, value TEXT)' ); + + $this->assertSame( $result, $driver->get_last_return_value() ); + $this->assertSame( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)', $driver->get_last_mysql_query() ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'CREATE TABLE "t" (' . "\n" . ' "id" integer PRIMARY KEY,' . "\n" . ' "value" text' . "\n" . ')', + ) + ); + $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 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 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', + $this->remove_real_pgsql_test_schema_qualifiers( $queries[0]['sql'] ) + ); + } + + /** + * Tests INSERT ... SELECT accepts MySQL's optional INTO keyword. + */ + public function test_insert_select_without_into_is_translated_with_postgresql_into(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_without_into ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + + $this->assertSame( + 1, + $driver->query( "INSERT wptests_insert_select_without_into (`id`, `value`) SELECT 1, 'one' FROM DUAL" ) + ); + $this->assertSame( + 'INSERT INTO wptests_insert_select_without_into ("id", "value") SELECT 1, \'one\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_select_without_into' ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + } + + /** + * Tests columnless INSERT ... SELECT infers target columns from MySQL metadata. + */ + public function test_columnless_insert_select_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_columnless ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_insert_select_columnless ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_insert_select_columnless_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_insert_select_columnless_source (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $insert = 'INSERT INTO wptests_insert_select_columnless + SELECT id, value FROM wptests_insert_select_columnless_source WHERE 1 = 1'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO wptests_insert_select_columnless ("id", "value") SELECT id, value FROM wptests_insert_select_columnless_source WHERE 1 = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_select_columnless ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'one', + ), + (object) array( + 'id' => '2', + 'value' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests unsupported generic DML shapes fail closed in the narrow translators. + */ + public function test_unsupported_simple_dml_shapes_return_null_translation(): void { + $driver = $this->create_driver(); + + $unsupported_query_methods = array( + 'UPDATE wptests_unsupported, wptests_other SET wptests_other.id = wptests_unsupported.id, wptests_unsupported.id = wptests_other.id WHERE wptests_other.id = 1' => 'translate_simple_mysql_update_query', + ); + + foreach ( $unsupported_query_methods as $query => $method_name ) { + if ( 'translate_simple_mysql_insert_query' === $method_name || 'translate_simple_mysql_replace_query' === $method_name ) { + $this->assertNull( + $this->translate_driver_query_data_with_private_method( $driver, $method_name, $query ), + $query + ); + continue; + } + + $this->assertNull( + $this->translate_driver_query_with_private_method( $driver, $method_name, $query ), + $query + ); + } + } + + /** + * Tests AUTO_INCREMENT zero values generate IDs unless NO_AUTO_VALUE_ON_ZERO is active. + */ + public function test_auto_increment_zero_respects_no_auto_value_on_zero_sql_mode(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (0, 'zero')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_title") VALUES (DEFAULT, \'zero\')', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES ('0', 'quoted zero')" ) ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (0, 'literal zero')" ) ); + $this->assertContains( + 'INSERT INTO "wptests_posts" ("ID", "post_title") VALUES (0, \'literal zero\')', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_posts ORDER BY ID' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'zero' ), + array( '2', 'quoted zero' ), + ), + array_map( + static function ( $row ): array { + return array( $row->ID, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to ON DUPLICATE KEY UPDATE VALUES rows. + */ + public function test_auto_increment_zero_respects_sql_mode_for_upsert_values(): void { + $driver = $this->create_driver(); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (0, 'generated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (DEFAULT, \'generated\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (0, 'literal zero') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertContains( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (0, \'literal zero\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert ORDER BY id' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to INSERT ... SET and REPLACE rows. + */ + public function test_auto_increment_zero_respects_sql_mode_for_insert_set_and_replace(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_zero_dml ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_zero_dml SET ID = 0, post_title = 'set-generated'" ) ); + $this->assertSame( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (DEFAULT, \'set-generated\')', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $this->assertSame( 1, $driver->query( "REPLACE INTO wptests_zero_dml (`ID`, `post_title`) VALUES ('0', 'replace-generated')" ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (DEFAULT, \'replace-generated\')', + ) + ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_zero_dml SET ID = 0, post_title = 'set-literal'" ) ); + $this->assertContains( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (0, \'set-literal\')', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $this->assertSame( 2, $driver->query( "REPLACE INTO wptests_zero_dml (`ID`, `post_title`) VALUES (0, 'replace-literal')" ) ); + $this->assertContains( + 'DELETE FROM "wptests_zero_dml" WHERE ("ID" = 0)', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertContains( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (0, \'replace-literal\')', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT `ID` AS id, post_title FROM wptests_zero_dml ORDER BY `ID`' ); + $this->assertSame( + array( + array( '0', 'replace-literal' ), + array( '1', 'set-generated' ), + array( '2', 'replace-generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to INSERT ... SELECT projections. + */ + public function test_auto_increment_zero_respects_sql_mode_for_insert_select(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_insert_select_posts` (`ID`, `post_title`) SELECT 0, 'generated' FROM DUAL" ) ); + $generated_insert_select_sql = array_map( + static function ( string $sql ): string { + return (string) preg_replace( + "/'wp_pg_test_[0-9a-f]{16}[.]wptests_insert_select_posts'/", + "'wp_pg_test_schema.wptests_insert_select_posts'", + $sql + ); + }, + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertContains( + 'INSERT INTO "wptests_insert_select_posts" ("ID", "post_title") SELECT nextval(pg_get_serial_sequence(\'wp_pg_test_schema.wptests_insert_select_posts\', \'ID\')) , \'generated\'', + $generated_insert_select_sql + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_insert_select_posts` (`ID`, `post_title`) SELECT 0, 'literal zero' FROM DUAL" ) ); + $this->assertContains( + 'INSERT INTO "wptests_insert_select_posts" ("ID", "post_title") SELECT 0, \'literal zero\'', + $this->get_normalized_last_postgresql_sql_statements( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_insert_select_posts ORDER BY ID' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->ID, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests inserts omitting AUTO_INCREMENT skip identity sequence repair work. + */ + public function test_real_pgsql_implicit_auto_increment_insert_skips_identity_sequence_repair(): void { + $driver = $this->create_real_pgsql_driver( 'wptests', true ); + + $driver->query( + 'CREATE TABLE seq_repair_implicit ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $logged_sql = array(); + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_implicit (`value`) VALUES ('first')" ) ); + $this->assertSame( 1, $driver->get_insert_id() ); + $this->assertSame( 0, $this->count_identity_sequence_repair_metadata_queries( $logged_sql ) ); + $this->assertSame( array(), $this->get_identity_sequence_repair_queries( $driver->get_last_postgresql_queries() ) ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_implicit (`value`) VALUES ('second')" ) ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT id, value FROM seq_repair_implicit ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'first' ), + array( '2', 'second' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests explicit AUTO_INCREMENT inserts still repair the PostgreSQL identity sequence. + */ + public function test_real_pgsql_explicit_auto_increment_insert_repairs_identity_sequence(): void { + $driver = $this->create_real_pgsql_driver( 'wptests', true ); + + $driver->query( + 'CREATE TABLE seq_repair_explicit ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_explicit (`id`, `value`) VALUES (10, 'explicit')" ) ); + $this->assertSame( 10, $driver->get_insert_id() ); + + $repair_queries = $this->get_identity_sequence_repair_queries( $driver->get_last_postgresql_queries() ); + $this->assertCount( 1, $repair_queries ); + $this->assertStringContainsString( + '"seq_repair_explicit_id_seq"', + $repair_queries[0]['params'][0] + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_explicit (`value`) VALUES ('implicit')" ) ); + $this->assertSame( 11, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT id, value FROM seq_repair_explicit ORDER BY id' ); + $this->assertSame( + array( + array( '10', 'explicit' ), + array( '11', 'implicit' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests explicit DEFAULT AUTO_INCREMENT values still use generated identity values. + */ + public function test_real_pgsql_default_auto_increment_insert_uses_generated_identity_value(): void { + $driver = $this->create_real_pgsql_driver( 'wptests', true ); + + $driver->query( + 'CREATE TABLE seq_repair_default ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_default (`id`, `value`) VALUES (DEFAULT, 'default')" ) ); + $this->assertSame( 1, $driver->get_insert_id() ); + $this->assertSame( array(), $this->get_identity_sequence_repair_queries( $driver->get_last_postgresql_queries() ) ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_default (`value`) VALUES ('implicit')" ) ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT id, value FROM seq_repair_default ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'default' ), + array( '2', 'implicit' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests inserts into tables without AUTO_INCREMENT skip identity sequence repair work. + */ + public function test_real_pgsql_insert_into_table_without_auto_increment_uses_identity_eligibility_cache_and_invalidates_on_ddl(): void { + $driver = $this->create_real_pgsql_driver( 'wptests', true ); + + $driver->query( + 'CREATE TABLE seq_repair_plain ( + id int NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $logged_sql = array(); + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_plain (`id`, `value`) VALUES (1, 'first')" ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_plain (`id`, `value`) VALUES (2, 'second')" ) ); + $this->assertSame( 0, $this->count_dml_identity_eligibility_metadata_queries( $logged_sql ) ); + $this->assertSame( 0, $this->count_identity_sequence_repair_metadata_queries( $logged_sql ) ); + $this->assertSame( array(), $this->get_identity_sequence_repair_queries( $driver->get_last_postgresql_queries() ) ); + + $rows = $driver->query( 'SELECT id, value FROM seq_repair_plain ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'first' ), + array( '2', 'second' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE seq_repair_plain CHANGE COLUMN `id` `id` int NOT NULL AUTO_INCREMENT' ) ); + + $logged_sql = array(); + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_plain (`id`, `value`) VALUES (10, 'explicit')" ) ); + $this->assertSame( 10, $driver->get_insert_id() ); + $this->assertSame( 1, $this->count_dml_identity_eligibility_metadata_queries( $logged_sql ) ); + $this->assertSame( 1, $this->count_identity_sequence_repair_metadata_queries( $logged_sql ) ); + + $repair_queries = $this->get_identity_sequence_repair_queries( $driver->get_last_postgresql_queries() ); + $this->assertCount( 1, $repair_queries ); + $this->assertStringContainsString( + '"seq_repair_plain_id_seq"', + $repair_queries[0]['params'][0] + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO seq_repair_plain (`value`) VALUES ('implicit')" ) ); + $this->assertSame( 11, $driver->get_insert_id() ); + $this->assertSame( 1, $this->count_dml_identity_eligibility_metadata_queries( $logged_sql ) ); + $this->assertSame( 1, $this->count_identity_sequence_repair_metadata_queries( $logged_sql ) ); + + $rows = $driver->query( 'SELECT id, value FROM seq_repair_plain ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'first' ), + array( '2', 'second' ), + array( '10', 'explicit' ), + array( '11', 'implicit' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests WooCommerce-style session upserts with WordPress %i double-quoted table identifiers. + */ + public function test_double_quoted_table_identifier_upsert_and_select_are_translated(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_woocommerce_sessions ( + session_id INTEGER PRIMARY KEY, + session_key TEXT NOT NULL UNIQUE, + session_value TEXT NOT NULL, + session_expiry INTEGER NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_woocommerce_sessions ( + session_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + session_key char(32) NOT NULL, + session_value longtext NOT NULL, + session_expiry bigint(20) unsigned NOT NULL, + PRIMARY KEY (session_id), + UNIQUE KEY session_key (session_key) + )' + ); + + $upsert = "INSERT INTO \"wptests_woocommerce_sessions\" (`session_key`, `session_value`, `session_expiry`) + VALUES ('session-key', 'first', 1781870576) + ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_woocommerce_sessions" ("session_key", "session_value", "session_expiry") VALUES (\'session-key\', \'first\', 1781870576) ON CONFLICT ("session_key") DO UPDATE SET "session_value" = excluded."session_value", "session_expiry" = excluded."session_expiry"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $duplicate_upsert = "INSERT INTO \"wptests_woocommerce_sessions\" (`session_key`, `session_value`, `session_expiry`) + VALUES ('session-key', 'second', 1781870577) + ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)"; + + $this->assertSame( 1, $driver->query( $duplicate_upsert ) ); + + $rows = $driver->query( "SELECT session_value, session_expiry FROM \"wptests_woocommerce_sessions\" WHERE session_key = 'session-key'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'second', $rows[0]->session_value ); + $this->assertSame( '1781870577', $rows[0]->session_expiry ); + $this->assertSame( + 'SELECT session_value, session_expiry FROM "wptests_woocommerce_sessions" WHERE session_key = \'session-key\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $count_rows = $driver->query( "SELECT COUNT(*) AS session_count FROM \"wptests_woocommerce_sessions\" WHERE session_key = 'session-key'" ); + + $this->assertCount( 1, $count_rows ); + $this->assertSame( '1', $count_rows[0]->session_count ); + $this->assertSame( + 'SELECT COUNT(*) AS session_count FROM "wptests_woocommerce_sessions" WHERE session_key = \'session-key\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) upsert assignments fail closed. + */ + public function test_upsert_last_insert_id_assignment_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'existing', 'old')" ); + + $queries = array( + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated') + ON DUPLICATE KEY UPDATE `value` = LAST_INSERT_ID(`id`)", + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id` + 1)", + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated'), ('new', 'created') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`)", + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LAST_INSERT_ID() upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests 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, value TEXT)' ); + $driver->rollBack(); + + $this->assertFalse( $this->postgresql_relation_exists( $driver, 't' ) ); + } + + /** + * 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->beginTransaction(); + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + $driver->query( "INSERT INTO t (id, value) VALUES (1, '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, 'pg_catalog.pg_index' ) + || ( + false !== strpos( $sql, 'index_list' ) + && false !== strpos( $sql, 'wptests_options' ) + ) + ) { + ++$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->assert_last_postgresql_sql_statements( + $driver, + array( + '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"', + ) + ); + + $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->assert_last_postgresql_sql_statements( + $driver, + array( + '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"', + ) + ); + + $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 ); + + $this->install_mysql_schema_metadata_fixture( + $driver, + '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) + )' + ); + + $translated_update = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $update + ); + $this->assertIsArray( $translated_update ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->remove_real_pgsql_test_schema_qualifiers( (string) $translated_update['sql'] ) + ); + $this->assertSame( 2, $unique_index_metadata_queries ); + } + + /** + * Tests REPLACE reuses unique-index metadata within and across DML statements. + */ + public function test_replace_reuses_unique_index_metadata_rows(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $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, 'pg_catalog.pg_index' ) + || ( + false !== strpos( $sql, 'index_list' ) + && false !== strpos( $sql, 'wptests_options' ) + ) + ) { + ++$unique_index_metadata_queries; + } + } + ); + + $replace = "REPLACE INTO `wptests_options` (`option_name`) VALUES ('siteurl')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( 1, $unique_index_metadata_queries ); + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( 1, $unique_index_metadata_queries ); + } + + /** + * Tests INSERT ... SET upserts are normalized into PostgreSQL ON CONFLICT statements. + */ + public function test_insert_set_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $insert = "INSERT INTO `wptests_options` SET `option_name` = 'siteurl', + `option_value` = 'http://example.org', + `autoload` = 'yes' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + ) + ); + + $update = "INSERT INTO `wptests_options` SET `option_name` = 'siteurl', + `option_value` = 'http://example.net', + `autoload` = 'no' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests INSERT priority modifiers are accepted on ON DUPLICATE KEY UPDATE statements. + */ + public function test_insert_priority_modifier_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $insert = "INSERT LOW_PRIORITY INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + ) + ); + + $update = "INSERT HIGH_PRIORITY INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.net', 'no') + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests VALUES upserts without a column list infer table metadata order. + */ + public function test_values_upsert_without_column_list_uses_table_metadata_order(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE metadata_order_upsert ( + value TEXT NOT NULL, + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE metadata_order_upsert ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $insert = "INSERT INTO `metadata_order_upsert` VALUES (1, 'first', 'old') + ON DUPLICATE KEY UPDATE `slug` = VALUES(`slug`), + `value` = VALUES(`value`)"; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $insert + ); + + $this->assertIsArray( $translation ); + $this->assertSame( array( 'id', 'slug', 'value' ), $translation['columns'] ); + $this->assertSame( + 'INSERT INTO "metadata_order_upsert" ("id", "slug", "value") VALUES (1, \'first\', \'old\') ON CONFLICT ("id") DO UPDATE SET "slug" = excluded."slug", "value" = excluded."value"', + $translation['sql'] + ); + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "metadata_order_upsert" ("id", "slug", "value") VALUES (1, \'first\', \'old\') ON CONFLICT ("id") DO UPDATE SET "slug" = excluded."slug", "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $update = "INSERT INTO `metadata_order_upsert` VALUES (1, 'second', 'new') + ON DUPLICATE KEY UPDATE `slug` = VALUES(`slug`), + `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + + $rows = $driver->query( 'SELECT id, slug, value FROM metadata_order_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'second', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports literal assignment expressions. + */ + public function test_options_upsert_literal_assignments_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'yes')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net', + `autoload` = \"no\""; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'inserted\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = \'http://example.net\', "autoload" = \'no\'', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests unnamed MySQL UNIQUE KEY metadata participates in ON DUPLICATE KEY UPDATE. + */ + public function test_upsert_uses_unnamed_unique_key_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_unnamed_unique_upsert ( + id int(11) NOT NULL, + name varchar(255) DEFAULT NULL, + other varchar(255) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY (name) + )' + ) + ); + + $insert = 'INSERT INTO wptests_unnamed_unique_upsert (id, `name`, other) + VALUES (1, "name", "test") + ON DUPLICATE KEY UPDATE `other` = values(other)'; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $upsert = 'INSERT INTO wptests_unnamed_unique_upsert (id, `name`, other) + VALUES (2, "name", "updated") + ON DUPLICATE KEY UPDATE `other` = values(other)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_unnamed_unique_upsert" ("id", "name", "other") VALUES (2, \'name\', \'updated\') ON CONFLICT (SUBSTR(CAST("name" AS text), 1, 191)) DO UPDATE SET "other" = excluded."other"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, name, other FROM wptests_unnamed_unique_upsert ORDER BY id' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'name', $rows[0]->name ); + $this->assertSame( 'updated', $rows[0]->other ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports INSERT IGNORE, qualified targets, and DEFAULT. + */ + public function test_options_upsert_supports_ignore_qualified_assignment_targets_and_default(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT IGNORE INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'off') + ON DUPLICATE KEY UPDATE `wptests_options`.`option_value` = VALUES(`option_value`), + `wptests`.`wptests_options`.`autoload` = DEFAULT"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'inserted\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = \'yes\'', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'inserted', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL DEFAULT(column) assignments. + */ + public function test_upsert_update_assignments_support_default_column_function(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_defaults ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + note TEXT, + counter INTEGER NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "CREATE TABLE wptests_upsert_defaults ( + id bigint(20) unsigned NOT NULL, + label varchar(20) NOT NULL DEFAULT 'untitled', + note longtext DEFAULT NULL, + counter int(11) NOT NULL DEFAULT 3, + PRIMARY KEY (id) + )" + ); + $driver->query( "INSERT INTO wptests_upsert_defaults (id, label, note, counter) VALUES (1, 'old', 'old-note', 9)" ); + + $upsert = "INSERT INTO `wptests_upsert_defaults` (`id`, `label`, `note`, `counter`) + VALUES (1, 'incoming', 'incoming-note', 99) + ON DUPLICATE KEY UPDATE `label` = DEFAULT(`label`), + `note` = DEFAULT(`note`), + `counter` = DEFAULT(`counter`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_defaults" ("id", "label", "note", "counter") VALUES (1, \'incoming\', \'incoming-note\', 99) ON CONFLICT ("id") DO UPDATE SET "label" = \'untitled\', "note" = NULL, "counter" = \'3\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT label, note, counter FROM wptests_upsert_defaults WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'untitled', $rows[0]->label ); + $this->assertNull( $rows[0]->note ); + $this->assertSame( '3', $rows[0]->counter ); + + try { + $driver->query( + "INSERT INTO `wptests_upsert_defaults` (`id`, `label`, `note`, `counter`) + VALUES (1, 'incoming', 'incoming-note', 99) + ON DUPLICATE KEY UPDATE `label` = DEFAULT(`missing`)" + ); + $this->fail( 'Expected unsupported DEFAULT(column) upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL DEFAULT(column) inside expressions. + */ + public function test_upsert_update_assignments_support_default_column_inside_expressions(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_default_expr ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + counter INTEGER NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "CREATE TABLE wptests_upsert_default_expr ( + id bigint(20) unsigned NOT NULL, + label varchar(20) NOT NULL DEFAULT 'untitled', + counter int(11) NOT NULL DEFAULT 3, + PRIMARY KEY (id) + )" + ); + $driver->query( "INSERT INTO wptests_upsert_default_expr (id, label, counter) VALUES (1, 'old', 9)" ); + + $upsert = "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) + VALUES (1, 'incoming', 4) + ON DUPLICATE KEY UPDATE `counter` = DEFAULT(`counter`) + VALUES(`counter`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_default_expr" ("id", "label", "counter") VALUES (1, \'incoming\', 4) ON CONFLICT ("id") DO UPDATE SET "counter" = \'3\' + excluded."counter"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT label, counter FROM wptests_upsert_default_expr WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'old', $rows[0]->label ); + $this->assertSame( '7', $rows[0]->counter ); + + foreach ( + array( + "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) VALUES (1, 'incoming', 4) ON DUPLICATE KEY UPDATE `counter` = DEFAULT(`missing`) + 1", + "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) VALUES (1, 'incoming', 4) ON DUPLICATE KEY UPDATE `counter` = DEFAULT + 1", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DEFAULT(column) expression to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE translates CURRENT_TIMESTAMP metadata defaults as expressions. + */ + public function test_upsert_default_assignments_translate_current_timestamp_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_current_timestamp_defaults ( + id INTEGER PRIMARY KEY, + updated_at TEXT NOT NULL, + touched_at TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_current_timestamp_defaults ( + id bigint(20) unsigned NOT NULL, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + touched_at timestamp NOT NULL DEFAULT (now()), + PRIMARY KEY (id) + )' + ); + + $default_column_upsert = "INSERT INTO `wptests_upsert_current_timestamp_defaults` (`id`, `updated_at`, `touched_at`) + VALUES (1, '2001-01-01 00:00:00', '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DEFAULT(`updated_at`), + `touched_at` = DEFAULT(`touched_at`)"; + + $default_column_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $default_column_upsert + ); + + $this->assertIsArray( $default_column_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_current_timestamp_defaults" ("id", "updated_at", "touched_at") VALUES (1, \'2001-01-01 00:00:00\', \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'), "touched_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $default_column_translation['sql'] + ); + + $default_keyword_upsert = "INSERT INTO `wptests_upsert_current_timestamp_defaults` (`id`, `updated_at`, `touched_at`) + VALUES (1, '2001-01-01 00:00:00', '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DEFAULT"; + + $default_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $default_keyword_upsert + ); + + $this->assertIsArray( $default_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_current_timestamp_defaults" ("id", "updated_at", "touched_at") VALUES (1, \'2001-01-01 00:00:00\', \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $default_keyword_translation['sql'] + ); + } + + /** + * Tests INSERT ... SET upserts support qualified insert assignment targets. + */ + public function test_insert_set_upsert_supports_qualified_insert_assignment_targets(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT INTO `wptests_options` + SET `wptests_options`.`option_name` = 'siteurl', + `wptests`.`wptests_options`.`option_value` = 'from-set', + `autoload` = 'off' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'from-set\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value"', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'from-set', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests INSERT ... SET upserts support MySQL VALUES-row aliases. + */ + public function test_insert_set_upsert_supports_values_row_alias_expressions(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT INTO `wptests_options` + SET `option_name` = 'siteurl', + `option_value` = 'from-set-alias', + `autoload` = 'off' + AS incoming(name_alias, value_alias, autoload_alias) + ON DUPLICATE KEY UPDATE `option_value` = incoming.`value_alias`, + `autoload` = autoload_alias"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'from-set-alias\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'from-set-alias', $rows[0]->option_value ); + $this->assertSame( 'off', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE fails closed for unknown assignment qualifiers. + */ + public function test_options_upsert_rejects_unknown_assignment_qualifier(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'off') + ON DUPLICATE KEY UPDATE `other_table`.`option_value` = VALUES(`option_value`)"; + + try { + $driver->query( $upsert ); + $this->fail( 'Expected unsupported qualified upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE decodes text-targeted hex literals. + */ + public function test_upsert_update_assignments_decode_hex_literals_for_text_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_hex_values (value TEXT UNIQUE)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_hex_values ( + value varchar(20) NOT NULL, + UNIQUE KEY value (value) + )' + ); + $driver->query( "INSERT INTO wptests_hex_values (value) VALUES ('test')" ); + + $upsert = "INSERT INTO wptests_hex_values (value) + VALUES ('test') + ON DUPLICATE KEY UPDATE value = 0x61"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_hex_values" ("value") VALUES (\'test\') ON CONFLICT ("value") DO UPDATE SET "value" = \'a\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT value FROM wptests_hex_values' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'a', $rows[0]->value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE assignment literals use strict target-column coercion. + */ + public function test_upsert_update_assignments_use_strict_target_column_coercion(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 1)' ); + + $upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = '4.0'"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->int_value ); + + try { + $driver->query( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = '12abc'" + ); + $this->fail( 'Expected invalid upsert assignment value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect integer value: '12abc'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE integer assignments use MySQL coercion. + */ + public function test_non_strict_upsert_update_assignments_coerce_integer_string_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 123)' ); + + $upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = 'test'"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( "'test'" ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->int_value ); + } + + /** + * Tests non-strict scalar subquery upsert assignments use MySQL integer coercion. + */ + public function test_non_strict_upsert_scalar_subquery_assignments_coerce_integer_values(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 123)' ); + + $constant_upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = (SELECT 'test' FROM DUAL)"; + + $this->assertSame( 1, $driver->query( $constant_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( "(SELECT 'test')" ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->int_value ); + + $driver->query( + 'CREATE TABLE wptests_upsert_int_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_int_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_int_source (id, label) VALUES (1, '42suffix')" ); + + $table_upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = (SELECT `label` FROM `wptests_upsert_int_source` WHERE `id` = 1)'; + + $this->assertSame( 1, $driver->query( $table_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( '(SELECT "label" FROM "wptests_upsert_int_source" WHERE "id" = 1)' ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '42', $rows[0]->int_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes temporal scalar assignment literals. + */ + public function test_non_strict_upsert_update_assignments_normalize_temporal_boolean_and_zero_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $upsert = "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2026-01-01', '2026-01-01 00:00:00', '2026-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `date_value` = TRUE, + `datetime_value` = FALSE, + `timestamp_value` = 0"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'2026-01-01\', \'2026-01-01 00:00:00\', \'2026-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "date_value" = \'0000-00-00\', "datetime_value" = \'0000-00-00 00:00:00\', "timestamp_value" = \'0000-00-00 00:00:00\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes temporal scalar VALUES rows. + */ + public function test_non_strict_upsert_values_rows_normalize_temporal_boolean_and_zero_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $upsert = 'INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, TRUE, FALSE, 0) + ON DUPLICATE KEY UPDATE `date_value` = VALUES(`date_value`), + `datetime_value` = VALUES(`datetime_value`), + `timestamp_value` = VALUES(`timestamp_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'0000-00-00\', \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "date_value" = excluded."date_value", "datetime_value" = excluded."datetime_value", "timestamp_value" = excluded."timestamp_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE NULL assignments still fail for NOT NULL columns. + */ + public function test_non_strict_upsert_null_assignment_does_not_coerce_not_null_columns(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_upsert_not_null ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size INTEGER DEFAULT 123, + color TEXT + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_not_null ( + id int(11) NOT NULL, + name text NOT NULL, + size int(11) DEFAULT 123, + color text DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_not_null (id, name, size, color) VALUES (1, 'A', 10, 'red')" ); + + $upsert = "INSERT INTO `wptests_upsert_not_null` (`id`, `name`, `size`, `color`) + VALUES (1, 'B', 20, 'blue') + ON DUPLICATE KEY UPDATE `name` = NULL"; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + + $this->assertSame( + 'INSERT INTO "wptests_upsert_not_null" ("id", "name", "size", "color") VALUES (1, \'B\', 20, \'blue\') ON CONFLICT ("id") DO UPDATE SET "name" = NULL', + $translation['sql'] + ); + + try { + $driver->query( $upsert ); + $this->fail( 'Expected NOT NULL upsert assignment to fail.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'NOT NULL', $e->getMessage() ); + } + + $rows = $driver->query( 'SELECT name, size, color FROM wptests_upsert_not_null WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'A', $rows[0]->name ); + $this->assertSame( '10', $rows[0]->size ); + $this->assertSame( 'red', $rows[0]->color ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports current-row assignment expressions. + */ + public function test_upsert_update_assignments_support_current_row_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + 1'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 2) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '5', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL timestamp runtime functions. + */ + public function test_upsert_update_assignments_support_timestamp_runtime_functions(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_timestamps ( + id INTEGER PRIMARY KEY, + updated_at TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_timestamps ( + id bigint(20) unsigned NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_timestamps (id, updated_at) VALUES (1, '2000-01-01 00:00:00')" ); + + $now_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = NOW()"; + + $now_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $now_upsert + ); + $this->assertNotNull( $now_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $now_translation['sql'] + ); + + $current_timestamp_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP()"; + + $current_timestamp_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $current_timestamp_upsert + ); + $this->assertNotNull( $current_timestamp_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $current_timestamp_translation['sql'] + ); + + $current_timestamp_keyword_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP"; + + $current_timestamp_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $current_timestamp_keyword_upsert + ); + $this->assertNotNull( $current_timestamp_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $current_timestamp_keyword_translation['sql'] + ); + + $current_date_datetime_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_DATE"; + + $current_date_datetime_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $current_date_datetime_upsert + ); + $this->assertNotNull( $current_date_datetime_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = (TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD\') || \' 00:00:00\')', + $current_date_datetime_translation['sql'] + ); + + $if_datetime_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = IF(`id` = 1, NOW(), CURRENT_TIMESTAMP)"; + + $if_datetime_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $if_datetime_upsert + ); + $this->assertNotNull( $if_datetime_translation ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "updated_at" = CASE WHEN CAST(CASE WHEN', $if_datetime_translation['sql'] ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $if_datetime_translation['sql'] ); + + $coalesce_date_datetime_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = COALESCE(CURRENT_DATE, UTC_DATE())"; + + $coalesce_date_datetime_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $coalesce_date_datetime_upsert + ); + $this->assertNotNull( $coalesce_date_datetime_translation ); + $this->assertStringContainsString( "THEN CAST(COALESCE(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD'), TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD')) AS text) || ' 00:00:00'", $coalesce_date_datetime_translation['sql'] ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $coalesce_date_datetime_translation['sql'] ); + + $driver->query( + 'CREATE TABLE wptests_upsert_temporal_keywords ( + id INTEGER PRIMARY KEY, + date_value TEXT NOT NULL, + time_value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_temporal_keywords ( + id bigint(20) unsigned NOT NULL, + date_value date NOT NULL, + time_value time NOT NULL, + PRIMARY KEY (id) + )' + ); + + $temporal_keyword_upsert = "INSERT INTO `wptests_upsert_temporal_keywords` (`id`, `date_value`, `time_value`) + VALUES (1, '2001-01-01', '01:02:03') + ON DUPLICATE KEY UPDATE `date_value` = CURRENT_DATE, `time_value` = CURRENT_TIME"; + + $temporal_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $temporal_keyword_upsert + ); + $this->assertNotNull( $temporal_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_temporal_keywords" ("id", "date_value", "time_value") VALUES (1, \'2001-01-01\', \'01:02:03\') ON CONFLICT ("id") DO UPDATE SET "date_value" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD\'), "time_value" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'HH24:MI:SS\')', + $temporal_keyword_translation['sql'] + ); + + foreach ( + array( + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = IFNULL(NOW(), CURRENT_TIMESTAMP)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = IF(`id` = 1, NOW(), NULL)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = NULLIF(CURRENT_DATE, UTC_DATE())', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = COALESCE(NULL, CURRENT_DATE)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = DATE(NULL)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = GREATEST(NOW(), CURRENT_TIMESTAMP)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CAST(NULL AS DATETIME)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = DATE_ADD(NULL, INTERVAL 1 DAY)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = LEAST(CURRENT_DATE, UTC_DATE())', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = DATE_SUB(COALESCE(NOW(), CURRENT_TIMESTAMP), INTERVAL 1 DAY)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = (NOW()) + INTERVAL 10 MINUTE', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = IF(`id` = 1, NOW(), CURRENT_TIMESTAMP) + INTERVAL 1 DAY', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = DATE_ADD(IF(`id` = 1, NOW(), CURRENT_TIMESTAMP), INTERVAL 1 DAY)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CAST(NOW() AS DATETIME)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CAST(CURRENT_DATE AS TIMESTAMP)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = TIMESTAMPADD(DAY, 1, IFNULL(NOW(), CURRENT_TIMESTAMP))', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = TIMESTAMPADD(DAY, 1, CASE WHEN `id` = 1 THEN NOW() ELSE CURRENT_TIMESTAMP END)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = FROM_UNIXTIME(0)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = DATE_ADD(FROM_UNIXTIME(0), INTERVAL 1 DAY)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = DATE(COALESCE(NOW(), CURRENT_TIMESTAMP))', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = FROM_UNIXTIME(1609632000)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = FROM_UNIXTIME(1609632000, '%Y-%m-%d')", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = DATE_FORMAT(NULL, CONCAT('%Y', '-%m-%d'))", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => "`updated_at` = FROM_UNIXTIME(1609632000, '%Y-%m-%d %H:%i:%s')", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = DATE_ADD(FROM_UNIXTIME(NULL), INTERVAL 1 DAY)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = DATE_FORMAT(NOW(), '%Y-%m-%d')", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = DATE_ADD('2024-01-01', INTERVAL 1 DAY)", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = CONCAT('2024-', CONCAT('01', '-02'))", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = CONCAT_WS('-', '2024', CONCAT('0', '1'), '02')", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = CONCAT_WS('-', '2024', NULL, CONCAT('0', '1'), '02')", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => "`date_value` = CONCAT('2024-01-02', NULL)", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => "`updated_at` = DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => "`updated_at` = DATE_ADD('2024-01-01 02:03:04', INTERVAL 1 DAY)", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => "`updated_at` = DATE_ADD(CONCAT_WS(' ', CONCAT_WS('-', '2024', '01', '01'), CONCAT('02:', '03:04')), INTERVAL 1 DAY)", + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => "`updated_at` = DATE_FORMAT(CURRENT_DATE, '%Y-%m-%d')", + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = CONVERT(NOW(), DATE)', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = CAST(NOW() AS DATE)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CONVERT(CURRENT_DATE, DATE)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CAST(CURRENT_DATE AS DATE)', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CASE WHEN `id` = 1 THEN NOW() ELSE CURRENT_TIMESTAMP END', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CASE WHEN `id` = 1 THEN NOW() ELSE NULL END', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = CASE `id` WHEN 1 THEN CURRENT_DATE ELSE UTC_DATE() END', + ), + array( + 'table' => 'wptests_upsert_timestamps', + 'columns' => '`id`, `updated_at`', + 'values' => "1, '2001-01-01 00:00:00'", + 'assignment' => '`updated_at` = CASE WHEN `id` = 1 THEN NOW() ELSE CURRENT_TIMESTAMP END - INTERVAL 2 HOUR', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = (CASE `id` WHEN 1 THEN CURRENT_DATE ELSE UTC_DATE() END) + INTERVAL 1 DAY', + ), + array( + 'table' => 'wptests_upsert_temporal_keywords', + 'columns' => '`id`, `date_value`, `time_value`', + 'values' => "1, '2001-01-01', '01:02:03'", + 'assignment' => '`date_value` = DATE(CASE WHEN `id` = 1 THEN NOW() ELSE CURRENT_TIMESTAMP END)', + ), + ) as $wrapper_case + ) { + $wrapper_upsert = sprintf( + 'INSERT INTO `%s` (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s', + $wrapper_case['table'], + $wrapper_case['columns'], + $wrapper_case['values'], + $wrapper_case['assignment'] + ); + + $wrapper_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $wrapper_upsert + ); + $this->assertNotNull( $wrapper_translation, $wrapper_case['assignment'] ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $wrapper_translation['sql'], $wrapper_case['assignment'] ); + } + + $fractional_timestamp_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = NOW(6)"; + + $fractional_timestamp_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $fractional_timestamp_upsert + ); + $this->assertNotNull( $fractional_timestamp_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = LEFT(TO_CHAR(CURRENT_TIMESTAMP(6) AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS.US\'), 26)', + $fractional_timestamp_translation['sql'] + ); + + $now_sql = "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"; + + $date_add_sql = $this->get_expected_date_arithmetic_sql( '+', $now_sql, '1', 'day' ); + $date_add_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DATE_ADD(NOW(), INTERVAL 1 DAY)"; + + $date_add_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $date_add_upsert + ); + $this->assertNotNull( $date_add_translation ); + $this->assertSame( + sprintf( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(%s, \'YYYY-MM-DD HH24:MI:SS\')', + $date_add_sql + ), + $date_add_translation['sql'] + ); + + $date_sub_sql = $this->get_expected_date_arithmetic_sql( '-', $now_sql, '2', 'hour' ); + $date_sub_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR)"; + + $date_sub_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $date_sub_upsert + ); + $this->assertNotNull( $date_sub_translation ); + $this->assertSame( + sprintf( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(%s, \'YYYY-MM-DD HH24:MI:SS\')', + $date_sub_sql + ), + $date_sub_translation['sql'] + ); + + $infix_interval_sql = $this->get_expected_date_arithmetic_sql( '+', $now_sql, '10', 'minute' ); + $infix_interval_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = NOW() + INTERVAL 10 MINUTE"; + + $infix_interval_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $infix_interval_upsert + ); + $this->assertNotNull( $infix_interval_translation ); + $this->assertSame( + sprintf( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(%s, \'YYYY-MM-DD HH24:MI:SS\')', + $infix_interval_sql + ), + $infix_interval_translation['sql'] + ); + + $timestampadd_sql = $this->get_expected_date_arithmetic_sql( '+', $now_sql, '3', 'day' ); + $timestampadd_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = TIMESTAMPADD(DAY, 3, NOW())"; + + $timestampadd_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $timestampadd_upsert + ); + $this->assertNotNull( $timestampadd_translation ); + $this->assertSame( + sprintf( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(%s, \'YYYY-MM-DD HH24:MI:SS\')', + $timestampadd_sql + ), + $timestampadd_translation['sql'] + ); + + $current_date_sql = "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD')"; + $date_value_sql = $this->get_expected_date_arithmetic_sql( '+', $current_date_sql, '1', 'day' ); + $date_value_upsert = "INSERT INTO `wptests_upsert_temporal_keywords` (`id`, `date_value`, `time_value`) + VALUES (1, '2001-01-01', '01:02:03') + ON DUPLICATE KEY UPDATE `date_value` = DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY)"; + + $date_value_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $date_value_upsert + ); + $this->assertNotNull( $date_value_translation ); + $this->assertSame( + sprintf( + 'INSERT INTO "wptests_upsert_temporal_keywords" ("id", "date_value", "time_value") VALUES (1, \'2001-01-01\', \'01:02:03\') ON CONFLICT ("id") DO UPDATE SET "date_value" = TO_CHAR(%s, \'YYYY-MM-DD\')', + $date_value_sql + ), + $date_value_translation['sql'] + ); + + $date_function_upsert = "INSERT INTO `wptests_upsert_temporal_keywords` (`id`, `date_value`, `time_value`) + VALUES (1, '2001-01-01', '01:02:03') + ON DUPLICATE KEY UPDATE `date_value` = DATE(NOW())"; + + $date_function_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $date_function_upsert + ); + $this->assertNotNull( $date_function_translation ); + $this->assertStringContainsString( + "ON CONFLICT (\"id\") DO UPDATE SET \"date_value\" = CASE WHEN CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS text)", + $date_function_translation['sql'] + ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $date_function_translation['sql'] ); + + $if_date_upsert = "INSERT INTO `wptests_upsert_temporal_keywords` (`id`, `date_value`, `time_value`) + VALUES (1, '2001-01-01', '01:02:03') + ON DUPLICATE KEY UPDATE `date_value` = IF(`id` = 1, CURRENT_DATE, UTC_DATE())"; + + $if_date_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $if_date_upsert + ); + $this->assertNotNull( $if_date_translation ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "date_value" = SUBSTRING(CAST(CASE WHEN', $if_date_translation['sql'] ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $if_date_translation['sql'] ); + + $date_function_datetime_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DATE(NOW())"; + + $date_function_datetime_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $date_function_datetime_upsert + ); + $this->assertNotNull( $date_function_datetime_translation ); + $this->assertStringContainsString( + "ON CONFLICT (\"id\") DO UPDATE SET \"updated_at\" = (CASE WHEN CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS text)", + $date_function_datetime_translation['sql'] + ); + $this->assertStringContainsString( "|| ' 00:00:00')", $date_function_datetime_translation['sql'] ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $date_function_datetime_translation['sql'] ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports VALUES(column) inside simple expressions. + */ + public function test_upsert_update_assignments_support_values_inside_simple_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + excluded."int_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE validates current-row columns inside simple expressions. + */ + public function test_upsert_update_assignments_support_resolved_column_references_inside_simple_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = COALESCE(`int_value`, 0) + VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = COALESCE("wptests_strict_ints"."int_value", 0) + excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports CASE expressions around VALUES(column). + */ + public function test_upsert_update_assignments_support_case_expressions_around_values_references(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3), (1, 9) + ON DUPLICATE KEY UPDATE `int_value` = CASE + WHEN `int_value` > VALUES(`int_value`) THEN `int_value` + ELSE VALUES(`int_value`) + END'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = CASE WHEN "wptests_strict_ints"."int_value" > excluded."int_value" THEN "wptests_strict_ints"."int_value" ELSE excluded."int_value" END', + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 9) ON CONFLICT ("id") DO UPDATE SET "int_value" = CASE WHEN "wptests_strict_ints"."int_value" > excluded."int_value" THEN "wptests_strict_ints"."int_value" ELSE excluded."int_value" END', + ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '9', $rows[0]->int_value ); + + try { + $driver->query( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 5) + ON DUPLICATE KEY UPDATE `int_value` = CASE + WHEN `missing_column` > VALUES(`int_value`) THEN `int_value` + ELSE VALUES(`int_value`) + END' + ); + $this->fail( 'Expected unresolved CASE upsert expression column to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE rejects unknown current-row expression columns. + */ + public function test_upsert_update_assignments_reject_unknown_current_row_expression_columns(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + foreach ( + array( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 3) ON DUPLICATE KEY UPDATE `int_value` = COALESCE(`missing_column`, 0) + VALUES(`int_value`)', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 3) ON DUPLICATE KEY UPDATE `int_value` = `other`.`int_value` + VALUES(`int_value`)', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unknown upsert expression column to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests singular VALUE row-list upsert syntax is translated like VALUES. + */ + public function test_value_keyword_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUE (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = excluded."int_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->int_value ); + } + + /** + * Tests empty/default-row ON DUPLICATE KEY UPDATE forms fail closed. + */ + public function test_empty_default_row_upsert_forms_fail_closed(): void { + $driver = $this->create_driver(); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + foreach ( + array( + 'INSERT INTO `wptests_identity_upsert` VALUES () ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', + 'INSERT INTO `wptests_identity_upsert` () VALUES () ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected empty/default-row upsert to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports VALUES() for omitted table columns. + */ + public function test_upsert_update_assignments_support_values_for_omitted_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_omitted_values_upsert ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + note TEXT, + counter INTEGER NOT NULL, + bonus INTEGER NOT NULL DEFAULT 0 + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_omitted_values_upsert ( + id bigint(20) unsigned NOT NULL, + label varchar(191) NOT NULL, + note longtext DEFAULT NULL, + counter int(11) NOT NULL, + bonus int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_omitted_values_upsert (id, label, note, counter, bonus) VALUES (1, 'old', 'old-note', 5, 7)" ); + + $upsert = "INSERT INTO `wptests_omitted_values_upsert` (`id`, `label`, `counter`) + VALUES (1, 'new', 1) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), + `note` = VALUES(`note`), + `counter` = `counter` + VALUES(`bonus`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_omitted_values_upsert" ("id", "label", "counter") VALUES (1, \'new\', 1) ON CONFLICT ("id") DO UPDATE SET "label" = excluded."label", "note" = excluded."note", "counter" = "wptests_omitted_values_upsert"."counter" + excluded."bonus"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT label, note, counter, bonus FROM wptests_omitted_values_upsert WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'new', $rows[0]->label ); + $this->assertNull( $rows[0]->note ); + $this->assertSame( '5', $rows[0]->counter ); + $this->assertSame( '7', $rows[0]->bonus ); + } + + /** + * Tests omitted unique-key columns can use table defaults as upsert arbiters. + */ + public function test_upsert_uses_defaulted_omitted_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->get_connection()->query( + "CREATE TABLE wptests_default_unique_upsert ( + slug TEXT NOT NULL DEFAULT 'shared', + value TEXT NOT NULL, + UNIQUE (slug) + )" + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "CREATE TABLE wptests_default_unique_upsert ( + slug varchar(191) NOT NULL DEFAULT 'shared', + value longtext NOT NULL, + UNIQUE KEY slug (slug) + )" + ); + $driver->get_connection()->query( "INSERT INTO wptests_default_unique_upsert (slug, value) VALUES ('shared', 'old')" ); + + $update = "INSERT INTO `wptests_default_unique_upsert` (`value`) + VALUES ('updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_default_unique_upsert" ("value") VALUES (\'updated\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_default_unique_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'shared', $rows[0]->slug ); + $this->assertSame( 'updated', $rows[0]->value ); + + $driver->get_connection()->query( 'DELETE FROM wptests_default_unique_upsert' ); + + $insert = "INSERT INTO `wptests_default_unique_upsert` (`value`) + VALUES ('inserted') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_default_unique_upsert" ("value") VALUES (\'inserted\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_default_unique_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'shared', $rows[0]->slug ); + $this->assertSame( 'inserted', $rows[0]->value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL VALUES-row alias expressions. + */ + public function test_upsert_update_assignments_support_values_row_alias_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) AS incoming + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + incoming.`int_value`'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + + $column_alias_upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 11) AS incoming(row_id, new_value) + ON DUPLICATE KEY UPDATE `int_value` = new_value'; + + $this->assertSame( 1, $driver->query( $column_alias_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 11) ON CONFLICT ("id") DO UPDATE SET "int_value" = excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '11', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE preserves no-op self-assignment row counts. + */ + public function test_upsert_update_assignments_support_no_op_self_assignments(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 99) + ON DUPLICATE KEY UPDATE `int_value` = `int_value`'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 99) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->int_value ); + } + + /** + * Tests malformed VALUES-row aliases fail closed before reaching PostgreSQL. + */ + public function test_upsert_update_assignments_reject_malformed_values_row_aliases(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + + foreach ( + array( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming(row_id) ON DUPLICATE KEY UPDATE `int_value` = row_id', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming ON DUPLICATE KEY UPDATE `int_value` = incoming.missing', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming ON DUPLICATE KEY UPDATE `int_value` = incoming', + 'INSERT INTO `wptests_strict_ints` SET `id` = 1, `int_value` = 2 AS incoming(row_id) ON DUPLICATE KEY UPDATE `int_value` = row_id', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected malformed VALUES-row alias upsert to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests metadata-backed multi-row ON DUPLICATE KEY UPDATE statements. + */ + public function test_multi_row_on_duplicate_key_update_uses_metadata_conflict_target(): void { + $driver = $this->create_driver(); + + $this->install_term_relationships_table_with_mysql_metadata( $driver, 'custom_term_relationships' ); + + $insert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 1), (227, 710, 2) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 1), (227, 710, 2) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $upsert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 7), (227, 711, 3) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 7), (227, 711, 3) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + 'SELECT object_id, term_taxonomy_id, term_order + FROM custom_term_relationships + ORDER BY term_taxonomy_id' + ); + + $this->assertCount( 3, $rows ); + $this->assertSame( '227', $rows[0]->object_id ); + $this->assertSame( '709', $rows[0]->term_taxonomy_id ); + $this->assertSame( '7', $rows[0]->term_order ); + $this->assertSame( '710', $rows[1]->term_taxonomy_id ); + $this->assertSame( '2', $rows[1]->term_order ); + $this->assertSame( '711', $rows[2]->term_taxonomy_id ); + $this->assertSame( '3', $rows[2]->term_order ); + } + + /** + * Tests duplicate conflict keys in one ON DUPLICATE KEY UPDATE batch run sequentially. + */ + public function test_multi_row_on_duplicate_key_update_with_duplicate_conflict_values_runs_sequentially(): void { + $driver = $this->create_driver(); + + $this->install_term_relationships_table_with_mysql_metadata( $driver, 'custom_term_relationships' ); + + $insert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 1), (227, 709, 2) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 1) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 2) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + 'SELECT object_id, term_taxonomy_id, term_order + FROM custom_term_relationships' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '227', $rows[0]->object_id ); + $this->assertSame( '709', $rows[0]->term_taxonomy_id ); + $this->assertSame( '2', $rows[0]->term_order ); + } + + /** + * Tests INSERT ... SELECT ON DUPLICATE KEY UPDATE uses MySQL unique-key metadata. + */ + public function test_insert_select_on_duplicate_key_update_uses_metadata_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_plugin_lookup ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_plugin_lookup ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + + $insert = "INSERT INTO `wptests_plugin_lookup` (`source`, `external_id`, `attempts`, `payload`) + SELECT 'feed', 'abc', 1, 'first' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 1, \'first\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $update = "INSERT INTO `wptests_plugin_lookup` (`source`, `external_id`, `attempts`, `payload`) + SELECT 'feed', 'abc', 3, 'second' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_plugin_lookup WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests WooCommerce reserved-stock INSERT SELECT upserts with FROM DUAL predicates. + */ + public function test_woocommerce_reserved_stock_insert_select_on_duplicate_key_update(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_wc_reserved_stock ( + `order_id` bigint(20) unsigned NOT NULL, + `product_id` bigint(20) unsigned NOT NULL, + `stock_quantity` double NOT NULL DEFAULT 0, + `timestamp` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + `expires` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`order_id`, `product_id`) + )' + ); + + $insert = 'INSERT INTO wptests_wc_reserved_stock (order_id, product_id, stock_quantity, timestamp, expires) + SELECT 5001, 89, 2, NOW(), ( NOW() + INTERVAL 10 MINUTE ) FROM DUAL + WHERE ( SELECT 12 FOR UPDATE ) - ( SELECT IFNULL(SUM(stock_quantity), 0) FROM wptests_wc_reserved_stock WHERE product_id = 89 FOR UPDATE ) >= 2 + ON DUPLICATE KEY UPDATE expires = VALUES(expires), stock_quantity = VALUES(stock_quantity)'; + + $translate = new ReflectionMethod( WP_PostgreSQL_Driver::class, 'translate_mysql_on_duplicate_key_update_query' ); + $upsert = $translate->invoke( $driver, $insert ); + + $this->assertIsArray( $upsert ); + $this->assertTrue( $upsert['upsert_select_materialized'] ); + $this->assertStringContainsString( "INTERVAL '1 minute'", $upsert['materialize_statements'][1] ); + $this->assertStringNotContainsString( 'INTERVAL 10 MINUTE', $upsert['materialize_statements'][1] ); + $this->assertStringNotContainsString( 'FOR UPDATE', $upsert['materialize_statements'][1] ); + $this->assertSame( + 'INSERT INTO "wptests_wc_reserved_stock" ("order_id", "product_id", "stock_quantity", "timestamp", "expires") SELECT "__wp_pg_upsert_rows"."order_id", "__wp_pg_upsert_rows"."product_id", "__wp_pg_upsert_rows"."stock_quantity", "__wp_pg_upsert_rows"."timestamp", "__wp_pg_upsert_rows"."expires" FROM "__wp_pg_upsert_select_9a6b0de90d5c" AS "__wp_pg_upsert_rows" WHERE 1 = 1 ON CONFLICT ("order_id", "product_id") DO UPDATE SET "expires" = excluded."expires", "stock_quantity" = excluded."stock_quantity"', + $upsert['mutation_statements'][0] + ); + } + + /** + * Tests duplicate SELECT source keys replay with MySQL row-by-row upsert semantics. + */ + public function test_insert_select_on_duplicate_key_update_with_duplicate_source_conflict_keys_replays_rows_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_plugin_lookup_duplicate ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_plugin_lookup_duplicate_source ( + seq INTEGER NOT NULL, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_plugin_lookup_duplicate ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_plugin_lookup_duplicate_source ( + seq int(11) NOT NULL, + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL, + payload longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_plugin_lookup_duplicate (source, external_id, attempts, payload) VALUES ('feed', 'abc', 10, 'old')" ); + $driver->query( "INSERT INTO wptests_plugin_lookup_duplicate_source (seq, source, external_id, attempts, payload) VALUES (1, 'feed', 'abc', 1, 'first'), (2, 'feed', 'abc', 2, 'second')" ); + + $upsert = 'INSERT INTO `wptests_plugin_lookup_duplicate` (`source`, `external_id`, `attempts`, `payload`) + SELECT `source`, `external_id`, `attempts`, `payload` FROM `wptests_plugin_lookup_duplicate_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 7, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_upsert_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 2', $sql[4] ); + $this->assertStringContainsString( 'ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup_duplicate"."attempts" + excluded."attempts", "payload" = excluded."payload"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup_duplicate"."attempts" + excluded."attempts", "payload" = excluded."payload"', $sql[4] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_ord_[a-f0-9]{12}"$/', $sql[5] ); + $this->assertSame( $sql[0], $sql[6] ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_plugin_lookup_duplicate WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '13', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests columnless INSERT ... SELECT upserts infer target columns from MySQL metadata. + */ + public function test_columnless_insert_select_on_duplicate_key_update_uses_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_columnless_plugin_lookup ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_columnless_plugin_lookup ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + + $insert = "INSERT INTO `wptests_columnless_plugin_lookup` + SELECT 'feed', 'abc', 1, 'first' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 1, \'first\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_columnless_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $update = "INSERT INTO `wptests_columnless_plugin_lookup` + (SELECT 'feed', 'abc', 3, 'second' FROM DUAL) + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 3, \'second\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_columnless_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_columnless_plugin_lookup WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests ambiguous duplicate-key arbiters use the key that actually conflicts. + */ + public function test_ambiguous_on_duplicate_key_update_uses_conflicting_unique_key(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) VALUES (2, 'existing', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'existing\', \'new\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests multi-row upserts replay rows that conflict on different unique keys. + */ + public function test_multi_row_on_duplicate_key_update_replays_distinct_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + VALUES (1, 'fresh', 'updated-id'), (3, 'two', 'updated-slug') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (1, \'fresh\', \'updated-id\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (3, \'two\', \'updated-slug\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'updated-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'updated-slug', + ), + ), + $rows + ); + } + + /** + * Tests multi-row upserts replay incoming duplicates on a secondary unique key. + */ + public function test_multi_row_on_duplicate_key_update_replays_incoming_secondary_unique_conflicts(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + VALUES (1, 'shared', 'first'), (2, 'shared', 'second') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (1, \'shared\', \'first\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'shared\', \'second\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared', + 'value' => 'second', + ), + ), + $rows + ); + } + + /** + * Tests SELECT-sourced upserts resolve ambiguous targets from literal source rows. + */ + public function test_insert_select_on_duplicate_key_update_uses_conflicting_unique_key_for_literal_select(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` + SELECT 2, 'existing', 'new' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") SELECT 2, \'existing\', \'new\' ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests real SELECT-sourced upserts replay rows that conflict on different unique keys. + */ + public function test_insert_select_on_duplicate_key_update_replays_distinct_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'updated-id'), (2, 3, 'two', 'updated-slug')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['upsert_select_ambiguous_conflict_targets'] ); + $this->assertSame( array( 'id', 'slug' ), $translation['conflict_columns'] ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 7, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_upsert_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 2', $sql[4] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_ord_[a-f0-9]{12}"$/', $sql[5] ); + $this->assertSame( $sql[0], $sql[6] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'updated-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'updated-slug', + ), + ), + $rows + ); + } + + /** + * Tests columnless real SELECT-sourced upserts infer metadata columns for ambiguous arbiters. + */ + public function test_columnless_insert_select_on_duplicate_key_update_replays_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'updated-id'), (2, 3, 'two', 'updated-slug')" ); + + $upsert = 'INSERT INTO ambiguous_upsert + SELECT id, slug, value FROM ambiguous_upsert_source ORDER BY seq + ON DUPLICATE KEY UPDATE value = VALUES(value)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['upsert_select_ambiguous_conflict_targets'] ); + $this->assertStringContainsString( + 'INSERT INTO ambiguous_upsert ("id", "slug", "value") SELECT id, slug, value', + $translation['sql'] + ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertSame( array( 'updated-id', 'updated-slug' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests real SELECT-sourced ambiguous upserts support scalar subquery assignments. + */ + public function test_insert_select_on_duplicate_key_update_replays_ambiguous_conflict_targets_with_subquery_assignment(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'incoming-id'), (2, 3, 'two', 'incoming-slug')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = (SELECT 'subquery' FROM DUAL)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = CAST((SELECT \'subquery\') AS text)', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = CAST((SELECT \'subquery\') AS text)', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertSame( array( 'subquery', 'subquery' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests real SELECT-sourced upserts replay incoming conflicts on secondary unique keys. + */ + public function test_insert_select_on_duplicate_key_update_replays_incoming_secondary_unique_conflicts(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'shared', 'first'), (2, 2, 'shared', 'second')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared', + 'value' => 'second', + ), + ), + $rows + ); + } + + /** + * Tests real SELECT-sourced upserts use prefix unique expression conflict targets. + */ + public function test_insert_select_prefix_unique_on_duplicate_key_update_replays_expression_conflict_target(): void { + $driver = $this->create_driver(); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE prefix_ambiguous_source ( + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE prefix_ambiguous_source ( + id bigint(20) unsigned NOT NULL, + slug varchar(255) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO prefix_ambiguous (id, slug, value) VALUES (1, 'existing-slug-one', 'old')" ); + $driver->query( "INSERT INTO prefix_ambiguous_source (id, slug, value) VALUES (2, 'existing-slug-two', 'new')" ); + + $upsert = 'INSERT INTO `prefix_ambiguous` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `prefix_ambiguous_source` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( + 'ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $sql[3] + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM prefix_ambiguous' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests real SELECT-sourced rows that match multiple unique keys fail closed. + */ + public function test_insert_select_on_duplicate_key_update_rejects_rows_matching_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert_source ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (id, slug, value) VALUES (1, 'two', 'unsupported')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + try { + $driver->query( $upsert ); + $this->fail( 'Expected multi-conflict SELECT-sourced upsert row to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + } + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'old-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'old-slug', + ), + ), + $rows + ); + } + + /** + * Tests prefix unique duplicate-key arbiters use PostgreSQL expression conflicts. + */ + public function test_prefix_unique_on_duplicate_key_update_uses_expression_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE prefix_unique_upsert ( + slug varchar(255) NOT NULL, + value text NOT NULL, + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO prefix_unique_upsert (`slug`, `value`) VALUES ('existing-slug-one', 'old')" ); + + $upsert = "INSERT INTO `prefix_unique_upsert` (`slug`, `value`) VALUES ('existing-slug-two', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $this->assertSame( + 'INSERT INTO "prefix_unique_upsert" ("slug", "value") VALUES (\'existing-slug-two\', \'new\') ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $rows = $driver->query( 'SELECT slug, value FROM prefix_unique_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests missing column-list upserts infer columns before resolving ambiguous targets. + */ + public function test_missing_column_list_upsert_with_ambiguous_conflict_targets_uses_conflicting_key(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + $ambiguous_upsert = "INSERT INTO `ambiguous_upsert` VALUES (2, 'existing', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $ambiguous_upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'existing\', \'new\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO prefix_ambiguous (id, slug, value) VALUES (1, 'existing-slug-one', 'old')" ); + $prefix_upsert = "INSERT INTO `prefix_ambiguous` VALUES (2, 'existing-slug', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $prefix_upsert ) ); + $this->assertSame( + 'INSERT INTO "prefix_ambiguous" ("id", "slug", "value") VALUES (2, \'existing-slug\', \'new\') ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM prefix_ambiguous ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests simple WordPress UPDATE statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_update_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('key1', 'value1')" ); + + $update = "UPDATE `wp_options` SET `option_value` = 'value2' WHERE `option_name` = 'key1'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( $update, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\' WHERE ("option_name" = \'key1\') AND ("option_value" IS DISTINCT FROM (\'value2\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value FROM wp_options WHERE option_name = 'key1'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'value2', $rows[0]->option_value ); + } + + /** + * Tests UPDATE IGNORE skips rows that would violate unique constraints. + */ + public function test_update_ignore_unique_conflict_returns_zero_and_preserves_rows(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ignore_unique ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + note TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ignore_unique (id, slug, note) VALUES (1, 'a', 'first'), (2, 'b', 'second')" ); + + $update = "UPDATE IGNORE wptests_update_ignore_unique SET slug = 'a', note = 'changed' WHERE slug = 'b'"; + + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + 'UPDATE "wptests_update_ignore_unique" SET "slug" = \'a\', "note" = \'changed\' WHERE (slug = \'b\') AND ("slug" IS DISTINCT FROM (\'a\') OR "note" IS DISTINCT FROM (\'changed\'))', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, note FROM wptests_update_ignore_unique ORDER BY id' ); + + $this->assertSame( array( 'a', 'b' ), array_column( $rows, 'slug' ) ); + $this->assertSame( array( 'first', 'second' ), array_column( $rows, 'note' ) ); + } + + /** + * Tests simple WordPress UPDATE statements return changed rows, not matched rows. + */ + public function test_simple_wordpress_update_returns_zero_for_noop_update(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_parent INTEGER NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_comments (comment_ID, comment_parent, comment_content) VALUES (1, 0, 'first')" ); + + $update = "UPDATE `wp_comments` SET `comment_parent` = 2, `comment_content` = 'updated' WHERE `comment_ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_comments" SET "comment_parent" = 2, "comment_content" = \'updated\' WHERE ("comment_ID" = 1) AND ("comment_parent" IS DISTINCT FROM (2) OR "comment_content" IS DISTINCT FROM (\'updated\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT comment_parent, comment_content FROM wp_comments WHERE `comment_ID` = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->comment_parent ); + $this->assertSame( 'updated', $rows[0]->comment_content ); + } + + /** + * Tests WordPress UPDATE statements support IS NULL conditions. + */ + public function test_simple_wordpress_update_supports_is_null_where(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_null_where ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL, + meta_value TEXT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_null_where (meta_id, meta_key, meta_value) VALUES (1, 'null_update_where_key', NULL)" ); + + $update = "UPDATE `wptests_update_null_where` SET `meta_value` = 'null_update_where_key' WHERE `meta_key` = 'null_update_where_key' AND `meta_value` IS NULL"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_null_where" SET "meta_value" = \'null_update_where_key\' WHERE ("meta_key" = \'null_update_where_key\' AND "meta_value" IS NULL) AND ("meta_value" IS DISTINCT FROM (\'null_update_where_key\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_update_null_where WHERE meta_key = 'null_update_where_key'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'null_update_where_key', $rows[0]->meta_value ); + } + + /** + * Tests WordPress DELETE statements support IS NULL conditions. + */ + public function test_simple_wordpress_delete_supports_is_null_where(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_null_where ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL, + meta_value TEXT NULL + )' + ); + $driver->query( "INSERT INTO wptests_delete_null_where (meta_id, meta_key, meta_value) VALUES (1, 'null_update_where_key', NULL)" ); + + $delete = "DELETE FROM `wptests_delete_null_where` WHERE `meta_key` = 'null_update_where_key' AND `meta_value` IS NULL"; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_delete_null_where" WHERE "meta_key" = \'null_update_where_key\' AND "meta_value" IS NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_id FROM wptests_delete_null_where WHERE meta_key = 'null_update_where_key'" ); + + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple single-table UPDATE aliases and qualified assignment targets. + */ + public function test_simple_update_with_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_alias ( + id INTEGER PRIMARY KEY, + value INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_update_alias (id, value) VALUES (1, 4), (2, 8)' ); + + $update = 'UPDATE `wptests_update_alias` AS ua SET ua.value = ua.value + 1 WHERE ua.id = 1'; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_alias" AS "ua" SET "value" = ua.value + 1 WHERE (ua.id = 1) AND ("ua"."value" IS DISTINCT FROM (ua.value + 1))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_update_alias ORDER BY id' ); + $this->assertSame( '5', $rows[0]->value ); + $this->assertSame( '8', $rows[1]->value ); + } + + /** + * Tests leading WITH clauses are preserved for supported UPDATE statements. + */ + public function test_cte_prefixed_update_with_cte_predicate_subquery_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_cte ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + priority INTEGER NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_cte (id, status, priority) VALUES + (1, 'queued', 20), + (2, 'queued', 10), + (3, 'queued', 30)" + ); + + $update = "WITH picked (picked_id) AS ( + SELECT `id` FROM `wptests_update_cte` WHERE `priority` <= 20 + ) + UPDATE `wptests_update_cte` + SET `status` = 'claimed' + WHERE `id` IN (SELECT picked_id FROM picked)"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringStartsWith( 'WITH picked (picked_id) AS (SELECT "id" FROM "wptests_update_cte" WHERE "priority" <= 20) UPDATE "wptests_update_cte" SET "status" = \'claimed\'', $sql ); + $this->assertStringContainsString( '"id" IN (SELECT picked_id FROM picked)', $sql ); + $this->assertStringContainsString( '"status" IS DISTINCT FROM (\'claimed\')', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_cte ORDER BY id' ); + $this->assertSame( 'claimed', $rows[0]->status ); + $this->assertSame( 'claimed', $rows[1]->status ); + $this->assertSame( 'queued', $rows[2]->status ); + } + + /** + * Tests CTE-prefixed UPDATE keeps unsupported nested app-table SELECTs fail-closed. + */ + public function test_cte_prefixed_update_with_non_cte_nested_select_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_cte_unsupported ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + + try { + $driver->query( + "WITH picked AS (SELECT id FROM wptests_update_cte_unsupported) + UPDATE wptests_update_cte_unsupported + SET status = 'claimed' + WHERE id IN (SELECT id FROM wptests_update_cte_unsupported)" + ); + $this->fail( 'Expected unsupported CTE-prefixed UPDATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL inner joined UPDATE statements translate through PostgreSQL UPDATE FROM. + */ + public function test_inner_join_update_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined (id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined AS p JOIN wptests_update_joined_meta AS pm ON p.id = pm.post_id SET p.status = pm.meta_value WHERE pm.meta_key = '_status'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_joined" AS "p" SET "status" = pm.meta_value FROM "wptests_update_joined_meta" AS "pm" WHERE (p.id = pm.post_id) AND (pm.meta_key = \'_status\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests joined UPDATE ORDER BY without LIMIT is preserved in a derived source. + */ + public function test_joined_update_order_by_without_limit_preserves_ordering(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_order ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_order_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_order (ctid, id, status) VALUES (1, 1, 'draft'), (2, 2, 'draft'), (3, 3, 'publish')" ); + $driver->query( "INSERT INTO wptests_update_joined_order_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'private'), (2, '_status', 'scheduled'), (3, '_other', 'ignore')" ); + + $update = "UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key = '_status' + ORDER BY pm.meta_value ASC, p.id DESC"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_joined_order" AS "p" SET "status" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( "WHERE (p.id = pm.post_id) AND (pm.meta_key = '_status') ORDER BY pm.meta_value ASC, p.id DESC", $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_order ORDER BY id' ); + $this->assertSame( 'private', $rows[0]->status ); + $this->assertSame( 'scheduled', $rows[1]->status ); + $this->assertSame( 'publish', $rows[2]->status ); + } + + /** + * Tests joined UPDATE expression ORDER BY clauses without LIMIT are translated. + */ + public function test_joined_update_expression_order_by_without_limit_is_translated(): void { + $driver = $this->create_driver(); + + $update = "UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key = '_status' + ORDER BY LENGTH(pm.meta_value), p.id + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'ORDER BY CASE WHEN ', $sql ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(pm.meta_value AS text), \'UTF8\')) END, p.id + 0 DESC', $sql ); + } + + /** + * Tests bounded joined UPDATE ORDER BY/LIMIT forms update the intended matched row slice. + */ + public function test_joined_update_order_by_limit_updates_ordered_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_limit ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_limit_meta ( + post_id INTEGER NOT NULL, + priority INTEGER NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_joined_limit (ctid, id, status) VALUES + (1, 1, 'queued'), + (2, 2, 'queued'), + (3, 3, 'queued'), + (4, 4, 'done')" + ); + $driver->query( + "INSERT INTO wptests_update_joined_limit_meta (post_id, priority, meta_value) VALUES + (1, 30, 'third'), + (2, 10, 'first'), + (3, 20, 'second'), + (4, 1, 'skip')" + ); + + $update = "UPDATE wptests_update_joined_limit AS p + JOIN wptests_update_joined_limit_meta AS pm ON pm.post_id = p.id + SET p.status = pm.meta_value + WHERE p.status = 'queued' + ORDER BY pm.priority ASC, p.id DESC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( "WHERE (pm.post_id = p.id) AND (p.status = 'queued') ORDER BY pm.priority ASC, p.id DESC LIMIT 2 OFFSET 1", $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_limit ORDER BY id' ); + $this->assertSame( 'third', $rows[0]->status ); + $this->assertSame( 'queued', $rows[1]->status ); + $this->assertSame( 'second', $rows[2]->status ); + $this->assertSame( 'done', $rows[3]->status ); + + $comma_update = "UPDATE wptests_update_joined_limit AS p, wptests_update_joined_limit_meta AS pm + SET p.status = 'limited' + WHERE pm.post_id = p.id AND p.status = 'queued' + LIMIT 1"; + + $this->assertSame( 1, $driver->query( $comma_update ) ); + $this->assertStringContainsString( 'LIMIT 1', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests MySQL inner joined UPDATE statements support derived-table sources. + */ + public function test_inner_join_update_with_derived_source_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_derived ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_derived_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_derived (id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_derived_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined_derived AS p + JOIN ( + SELECT post_id, meta_value + FROM wptests_update_joined_derived_meta + WHERE meta_key = '_status' + ) AS src ON p.id = src.post_id + SET p.status = src.meta_value + WHERE p.id IN (1, 2)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'FROM (SELECT post_id, meta_value FROM wptests_update_joined_derived_meta WHERE meta_key = \'_status\') AS "src"', $sql ); + $this->assertStringContainsString( '(p.id = src.post_id)', $sql ); + $this->assertStringContainsString( '("p"."status" IS DISTINCT FROM (src.meta_value))', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_derived ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests Action Scheduler claim UPDATE uses ordered limited derived sources. + */ + public function test_action_scheduler_claim_update_with_ordered_limited_derived_source(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( '' ); + $driver->query( + "CREATE TABLE wptests_actionscheduler_actions ( + action_id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + scheduled_date_gmt TEXT NULL default '0000-00-00 00:00:00', + scheduled_date_local TEXT NULL default '0000-00-00 00:00:00', + priority INTEGER NOT NULL default '10', + attempts INTEGER NOT NULL default '0', + last_attempt_gmt TEXT NULL default '0000-00-00 00:00:00', + last_attempt_local TEXT NULL default '0000-00-00 00:00:00', + claim_id INTEGER NOT NULL default '0' + )" + ); + $driver->query( + "INSERT INTO wptests_actionscheduler_actions + (action_id, status, scheduled_date_gmt, scheduled_date_local, priority, attempts, claim_id) + VALUES + (1, 'pending', '2025-09-03 12:00:00', '2025-09-03 12:00:00', 20, 1, 0), + (2, 'pending', '2025-09-03 11:00:00', '2025-09-03 11:00:00', 5, 2, 0), + (3, 'complete', '2025-09-03 10:00:00', '2025-09-03 10:00:00', 1, 0, 0), + (4, 'pending', '2025-09-03 09:00:00', '2025-09-03 09:00:00', 1, 0, 9), + (5, 'pending', '2025-09-04 09:00:00', '2025-09-04 09:00:00', 1, 0, 0)" + ); + + $update = "UPDATE wptests_actionscheduler_actions t1 + JOIN ( + SELECT action_id + FROM wptests_actionscheduler_actions + WHERE claim_id = 0 AND scheduled_date_gmt <= '2025-09-03 12:23:55' AND status = 'pending' + ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC + LIMIT 2 + FOR UPDATE + ) t2 ON t1.action_id = t2.action_id + SET claim_id = 37, last_attempt_gmt = '2025-09-03 12:23:55', last_attempt_local = '2025-09-03 12:23:55'"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC LIMIT 2', $sql ); + $this->assertStringNotContainsString( 'FOR UPDATE', $sql ); + + $rows = $driver->query( 'SELECT action_id, claim_id, last_attempt_gmt, last_attempt_local FROM wptests_actionscheduler_actions ORDER BY action_id' ); + $this->assertSame( array( '37', '37', '0', '9', '0' ), array_column( $rows, 'claim_id' ) ); + $this->assertSame( '2025-09-03 12:23:55', $rows[0]->last_attempt_gmt ); + $this->assertSame( '2025-09-03 12:23:55', $rows[1]->last_attempt_local ); + $this->assertSame( '0000-00-00 00:00:00', $rows[2]->last_attempt_gmt ); + } + + /** + * Tests MySQL inner joined UPDATE ... USING statements translate to PostgreSQL predicates. + */ + public function test_inner_join_update_using_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_using ( + post_id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_using_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_using (post_id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_using_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined_using AS p INNER JOIN wptests_update_joined_using_meta AS pm USING (post_id) SET p.status = pm.meta_value WHERE pm.meta_key = '_status'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_joined_using" AS "p" SET "status" = pm.meta_value FROM "wptests_update_joined_using_meta" AS "pm" WHERE (p.post_id = pm.post_id) AND (pm.meta_key = \'_status\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT post_id, status FROM wptests_update_joined_using ORDER BY post_id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests joined UPDATE can target a non-leftmost table and MySQL join modifiers. + */ + public function test_joined_update_non_leftmost_target_and_join_modifiers_translate_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_join_source ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_join_target ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_join_source (id, value) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + $driver->query( "INSERT INTO wptests_update_join_target (id, value) VALUES (1, 'old'), (2, 'old'), (3, 'old')" ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE wptests_update_join_source AS s + JOIN wptests_update_join_target AS t ON t.id = s.id + SET t.value = s.value + WHERE s.id = 1' + ) + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_join_target" AS "t" SET "value" = s.value FROM "wptests_update_join_source" AS "s"', $sql ); + $this->assertStringContainsString( '(t.id = s.id)', $sql ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE LOW_PRIORITY wptests_update_join_target AS t + CROSS JOIN wptests_update_join_source AS s + SET t.value = s.value + WHERE t.id = s.id AND t.id = 2' + ) + ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE IGNORE wptests_update_join_target AS t + STRAIGHT_JOIN wptests_update_join_source AS s ON s.id = t.id + SET t.value = s.value + WHERE t.id = 3' + ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_update_join_target ORDER BY id' ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( 'two', $rows[1]->value ); + $this->assertSame( 'three', $rows[2]->value ); + } + + /** + * Tests joined UPDATE IGNORE skips rows that would violate unique constraints. + */ + public function test_joined_update_ignore_unique_conflict_returns_zero_and_preserves_rows(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ignore_join_target ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + note TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_ignore_join_source ( + id INTEGER PRIMARY KEY, + new_slug TEXT NOT NULL, + new_note TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ignore_join_target (id, slug, note) VALUES (1, 'a', 'first'), (2, 'b', 'second')" ); + $driver->query( "INSERT INTO wptests_update_ignore_join_source (id, new_slug, new_note) VALUES (2, 'a', 'changed')" ); + + $update = 'UPDATE IGNORE wptests_update_ignore_join_target AS t + JOIN wptests_update_ignore_join_source AS s ON s.id = t.id + SET t.slug = s.new_slug, t.note = s.new_note + WHERE t.id = 2'; + + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_ignore_join_target" AS "t" SET "slug" = s.new_slug, "note" = s.new_note', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_ignore_join_source" AS "s"', $sql ); + + $rows = $driver->query( 'SELECT id, slug, note FROM wptests_update_ignore_join_target ORDER BY id' ); + + $this->assertSame( array( 'a', 'b' ), array_column( $rows, 'slug' ) ); + $this->assertSame( array( 'first', 'second' ), array_column( $rows, 'note' ) ); + } + + /** + * Tests MySQL multi-target UPDATE statements translate to PostgreSQL writable CTEs. + */ + public function test_multi_target_update_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id AND s.id = 1'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_update_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "s".ctid AS "mysql_update_0_ctid", t.value AS "mysql_update_value_0", "t".ctid AS "mysql_update_1_ctid", s.value AS "mysql_update_value_1"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_source" AS "s", "wptests_update_target" AS "t"', $sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id AND s.id = 1)', $sql ); + $this->assertStringContainsString( 'mysql_update_target_0 AS (UPDATE "wptests_update_source" AS "s" SET "value" = mysql_update_rows."mysql_update_value_0" FROM mysql_update_rows WHERE ("s".ctid = mysql_update_rows."mysql_update_0_ctid")', $sql ); + $this->assertStringContainsString( 'mysql_update_target_1 AS (UPDATE "wptests_update_target" AS "t" SET "value" = mysql_update_rows."mysql_update_value_1" FROM mysql_update_rows WHERE ("t".ctid = mysql_update_rows."mysql_update_1_ctid")', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_update_target_0) + (SELECT COUNT(*) FROM mysql_update_target_1) AS affected_rows', $sql ); + + $join_update = 'UPDATE wptests_update_source AS s + JOIN wptests_update_target AS t ON t.id = s.id + SET s.value = t.value, t.value = s.value + WHERE s.id = 1'; + $join_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $join_update + ); + + $this->assertNotNull( $join_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) AND (s.id = 1)', $join_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $join_sql ); + + $ordered_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + ORDER BY t.id DESC, LENGTH(s.value)'; + $ordered_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $ordered_update + ); + + $this->assertNotNull( $ordered_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id)', $ordered_sql ); + $this->assertStringContainsString( 'ORDER BY t.id DESC, CASE WHEN ', $ordered_sql ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(s.value AS text), \'UTF8\')) END', $ordered_sql ); + + $limited_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + ORDER BY t.id DESC, s.value ASC + LIMIT 1, 2'; + $limited_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $limited_update + ); + + $this->assertNotNull( $limited_sql ); + $this->assertStringContainsString( 'WITH mysql_update_rows AS MATERIALIZED', $limited_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) ORDER BY t.id DESC, s.value ASC LIMIT 2 OFFSET 1', $limited_sql ); + + $limit_only_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + LIMIT 2'; + $limit_only_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $limit_only_update + ); + + $this->assertNotNull( $limit_only_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) LIMIT 2', $limit_only_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $limit_only_sql ); + + $single_target_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value + WHERE t.id = s.id'; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $single_target_update + ) + ); + } + + /** + * Tests joined UPDATE statements can read from information_schema sources. + */ + public function test_joined_update_can_read_information_schema_sources(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY it.table_name'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'UPDATE "wptests_options" AS "o" SET "option_value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'SELECT "o".ctid AS "mysql_update_target_ctid", "it"."TABLE_TYPE" AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( 'ORDER BY "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( '"o".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + $this->assertStringContainsString( '"o"."option_value" IS DISTINCT FROM ("mysql_update_values"."mysql_update_value_0")', $sql ); + } + + /** + * Tests unsupported information_schema joined UPDATE ORDER BY sources fail closed. + */ + public function test_joined_update_rejects_unsupported_information_schema_order_by_sources(): void { + $queries = array( + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY missing_alias.table_name', + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY it.missing_column', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests information_schema aliases remain read-only in joined UPDATE statements. + */ + public function test_joined_update_rejects_information_schema_set_targets(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET it.table_name = o.option_name'; + + try { + $driver->query( $update ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests information_schema joined UPDATE predicates can use nested current-database SELECTs. + */ + public function test_joined_update_can_read_information_schema_predicate_subqueries(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema IN (SELECT DATABASE())'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'UPDATE "wptests_options" AS "o" SET "option_value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" IN ( SELECT \'wptests\' )', $sql ); + $this->assertStringNotContainsString( 'DATABASE()', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests unsupported information_schema joined UPDATE predicates fail before backend execution. + */ + public function test_joined_update_rejects_unsupported_information_schema_predicates(): void { + $queries = array( + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema IN (SELECT DATABASE() UNION SELECT DATABASE())', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests RIGHT JOIN UPDATE statements translate through a derived ctid source. + */ + public function test_right_join_update_is_translated_through_derived_ctid_source(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_right_source ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_right_target ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + + $update = 'UPDATE wptests_update_right_source AS s + RIGHT JOIN wptests_update_right_target AS t ON t.id = s.id + SET s.value = t.value'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( 'UPDATE "wptests_update_right_source" AS "s" SET "value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM (SELECT "s".ctid AS "mysql_update_target_ctid", t.value AS "mysql_update_value_0" FROM wptests_update_right_source AS s RIGHT JOIN wptests_update_right_target AS t ON t.id = s.id', $sql ); + $this->assertStringContainsString( '"s".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + } + + /** + * Tests joined UPDATE unsupported ORDER BY/LIMIT shapes fail before backend execution. + */ + public function test_joined_update_unsupported_order_by_and_limit_shapes_fail_closed_before_backend_execution(): void { + $driver = $this->create_driver(); + + $updates = array( + 'multi_target_bad_count' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY p.id ASC + LIMIT bad', + 'multi_target_bad_offset' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY p.id ASC + LIMIT 1, bad', + 'joined_bad_order_alias' => 'UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.post_id = p.id + ORDER BY missing_alias.id', + 'multi_target_bad_order_alias' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY missing_alias.id', + ); + + foreach ( $updates as $label => $update ) { + try { + $driver->query( $update ); + $this->fail( 'Expected unsupported UPDATE statement to throw for ' . $label . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $label ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $label ); + } + } + } + + /** + * Tests MySQL comma/join UPDATE statements translate through PostgreSQL UPDATE FROM. + */ + public function test_comma_join_update_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_multi_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + comment TEXT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_multi_source_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_multi_source_terms ( + post_id INTEGER NOT NULL, + term TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_multi_source (id, status) VALUES (1, 'draft'), (2, 'draft'), (3, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_multi_source_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_status', 'private'), (3, '_other', 'ignore')" ); + $driver->query( "INSERT INTO wptests_update_multi_source_terms (post_id, term) VALUES (1, 'publish'), (2, 'skip'), (3, 'publish')" ); + + $update = "UPDATE wptests_update_multi_source AS p, wptests_update_multi_source_meta AS pm + JOIN wptests_update_multi_source_terms AS tt ON tt.post_id = p.id + SET p.status = pm.meta_value + WHERE pm.post_id = p.id + AND pm.meta_key = '_status' + AND tt.term = 'publish'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_multi_source" AS "p" SET "status" = pm.meta_value FROM "wptests_update_multi_source_meta" AS "pm", "wptests_update_multi_source_terms" AS "tt" WHERE (tt.post_id = p.id) AND (pm.post_id = p.id AND pm.meta_key = \'_status\' AND tt.term = \'publish\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_multi_source ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + $this->assertSame( 'draft', $rows[2]->status ); + + $unqualified_update = "UPDATE wptests_update_multi_source AS p, wptests_update_multi_source_meta AS pm + JOIN wptests_update_multi_source_terms AS tt ON tt.post_id = p.id + SET comment = 'review' + WHERE pm.post_id = p.id + AND pm.meta_key = '_status' + AND tt.term = 'publish'"; + + $this->assertSame( 1, $driver->query( $unqualified_update ) ); + $this->assertSame( 0, $driver->query( $unqualified_update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_multi_source" AS "p" SET "comment" = \'review\'', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_multi_source_meta" AS "pm", "wptests_update_multi_source_terms" AS "tt"', $sql ); + + $rows = $driver->query( 'SELECT id, comment FROM wptests_update_multi_source ORDER BY id' ); + $this->assertSame( 'review', $rows[0]->comment ); + $this->assertNull( $rows[1]->comment ); + $this->assertNull( $rows[2]->comment ); + } + + /** + * Tests mixed comma/JOIN UPDATE resolves unqualified SET targets by column ownership. + */ + public function test_comma_join_update_unqualified_set_target_resolves_non_leftmost_table(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_mixed_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_target ( + id INTEGER PRIMARY KEY, + comment TEXT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_filter ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_mixed_source (id, status) VALUES (1, 'draft'), (2, 'publish'), (3, 'publish')" ); + $driver->query( 'INSERT INTO wptests_update_mixed_target (id, comment) VALUES (1, NULL), (2, NULL), (3, NULL)' ); + $driver->query( "INSERT INTO wptests_update_mixed_filter (id, name) VALUES (1, 'update'), (2, 'skip'), (3, 'update')" ); + + $update = "UPDATE wptests_update_mixed_source AS s, wptests_update_mixed_target AS t + JOIN wptests_update_mixed_filter AS f ON f.id = s.id + SET comment = 'updated' + WHERE t.id = s.id + AND t.id = f.id + AND s.status = 'publish' + AND f.name = 'update'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_mixed_target" AS "t" SET "comment" = \'updated\'', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_mixed_source" AS "s", "wptests_update_mixed_filter" AS "f"', $sql ); + $this->assertStringContainsString( '(f.id = s.id)', $sql ); + $this->assertStringContainsString( '("t"."comment" IS DISTINCT FROM (\'updated\'))', $sql ); + + $rows = $driver->query( 'SELECT id, comment FROM wptests_update_mixed_target ORDER BY id' ); + $this->assertNull( $rows[0]->comment ); + $this->assertNull( $rows[1]->comment ); + $this->assertSame( 'updated', $rows[2]->comment ); + } + + /** + * Tests ambiguous unqualified joined UPDATE SET targets fail closed. + */ + public function test_comma_join_update_ambiguous_unqualified_set_target_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_target ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_filter ( + id INTEGER PRIMARY KEY + )' + ); + + try { + $driver->query( + "UPDATE wptests_update_mixed_ambiguous_source AS s, wptests_update_mixed_ambiguous_target AS t + JOIN wptests_update_mixed_ambiguous_filter AS f ON f.id = s.id + SET status = 'updated' + WHERE t.id = s.id + AND t.id = f.id" + ); + $this->fail( 'Expected unsupported UPDATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL LEFT JOIN UPDATE statements preserve unmatched source rows. + */ + public function test_left_join_update_is_translated_through_derived_ctid_source(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_left_joined ( + id INTEGER PRIMARY KEY, + author_id INTEGER NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_left_joined_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_left_joined (id, author_id, status) VALUES (1, 10, 'draft'), (2, 20, 'draft'), (3, 30, 'publish')" ); + $driver->query( "INSERT INTO wptests_update_left_joined_meta (post_id, meta_key, meta_value) VALUES (10, '_status', 'scheduled'), (10, '_other', 'ignored')" ); + + $update = "UPDATE wptests_update_left_joined AS p + LEFT JOIN wptests_update_left_joined_meta AS pm + ON pm.post_id = p.author_id AND pm.meta_key = '_status' + SET p.status = IFNULL(pm.meta_value, 'orphan') + WHERE p.status = 'draft' + ORDER BY LENGTH(p.status), p.id DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( + 'UPDATE "wptests_update_left_joined" AS "p" SET "status" = "mysql_update_values"."mysql_update_value_0" FROM (SELECT "p".ctid AS "mysql_update_target_ctid", COALESCE(pm.meta_value, \'orphan\') AS "mysql_update_value_0"', + $sql + ); + $this->assertStringContainsString( "WHERE p.status = 'draft' ORDER BY CASE WHEN ", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(p.status AS text), 'UTF8')) END, p.id DESC", $sql ); + $this->assertStringContainsString( '"p".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + } + + /** + * Tests bounded UPDATE ORDER BY/LIMIT forms translate through PostgreSQL ctid. + */ + public function test_simple_update_order_by_limit_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 4 WHERE value > 0 LIMIT 2' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 4 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE value > 0 LIMIT 2)) AND ("value" IS DISTINCT FROM (4))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 3 LIMIT 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 3 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" LIMIT 1)) AND ("value" IS DISTINCT FROM (3))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 9 WHERE id > 0 ORDER BY id DESC LIMIT 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 9 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 1)) AND ("value" IS DISTINCT FROM (9))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 7 WHERE id > 0 ORDER BY id DESC LIMIT 1, 2' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 7 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1)) AND ("value" IS DISTINCT FROM (7))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 5 WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 5 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1)) AND ("value" IS DISTINCT FROM (5))', + $sql + ); + } + + /** + * Tests unsupported bounded UPDATE ORDER BY/LIMIT forms fail before backend execution. + */ + public function test_unsupported_simple_update_order_by_limit_shapes_fail_closed_before_backend(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_unsupported_limit ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + + foreach ( + array( + "UPDATE wptests_update_unsupported_limit SET status = 'x' LIMIT bad", + "UPDATE wptests_update_unsupported_limit SET status = 'x' ORDER BY id LIMIT bad", + "UPDATE wptests_update_unsupported_limit SET status = 'x' ORDER BY id LIMIT 1, bad", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests bounded UPDATE ORDER BY/LIMIT/OFFSET updates the intended ordered slice. + */ + public function test_simple_update_order_by_limit_executes_ordered_offset_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_order_limit_exec ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + priority INTEGER NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_order_limit_exec (ctid, id, priority, status) VALUES + (1, 1, 40, 'queued'), + (2, 2, 20, 'queued'), + (3, 3, 10, 'queued'), + (4, 4, 30, 'queued')" + ); + + $update = "UPDATE wptests_update_order_limit_exec AS q + SET q.status = 'claimed' + WHERE q.status = 'queued' + ORDER BY q.priority ASC, q.id ASC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'ctid IN (SELECT "q".ctid FROM "wptests_update_order_limit_exec" AS "q"', $sql ); + $this->assertStringContainsString( 'ORDER BY q.priority ASC, q.id ASC LIMIT 2 OFFSET 1', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_order_limit_exec ORDER BY id' ); + $this->assertSame( 'queued', $rows[0]->status ); + $this->assertSame( 'claimed', $rows[1]->status ); + $this->assertSame( 'queued', $rows[2]->status ); + $this->assertSame( 'claimed', $rows[3]->status ); + } + + /** + * Tests simple UPDATE ORDER BY without LIMIT uses a ctid subquery. + */ + public function test_simple_update_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ordered ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ordered (ctid, id, status) VALUES (1, 1, 'draft'), (2, 2, 'publish'), (3, 3, 'draft')" ); + + $update = "UPDATE `wptests_update_ordered` SET `status` = 'archived' WHERE `status` = 'draft' ORDER BY `id` DESC"; + + $this->assertSame( 2, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_ordered" SET "status" = \'archived\' WHERE (ctid IN (SELECT ctid FROM "wptests_update_ordered" WHERE "status" = \'draft\' ORDER BY "id" DESC)) AND ("status" IS DISTINCT FROM (\'archived\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_ordered ORDER BY id' ); + $this->assertSame( 'archived', $rows[0]->status ); + $this->assertSame( 'publish', $rows[1]->status ); + $this->assertSame( 'archived', $rows[2]->status ); + } + + /** + * Tests simple UPDATE translates expression ORDER BY clauses without LIMIT. + */ + public function test_simple_update_expression_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $update = "UPDATE wptests_update_order_expression SET `status` = 'archived' WHERE `status` = 'draft' ORDER BY LENGTH(`status`), `id` + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( 'UPDATE "wptests_update_order_expression" SET "status" = \'archived\' WHERE (ctid IN (SELECT ctid FROM "wptests_update_order_expression" WHERE "status" = \'draft\' ORDER BY ', $sql ); + $this->assertStringContainsString( 'OCTET_LENGTH(CONVERT_TO(CAST("status" AS text), \'UTF8\'))', $sql ); + $this->assertStringContainsString( ', "id" + 0 DESC)) AND ("status" IS DISTINCT FROM (\'archived\'))', $sql ); + } + + /** + * Tests simple UPDATE preserves a MySQL literal ending in an escaped backslash. + */ + public function test_simple_update_preserves_trailing_escaped_backslash_literal(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_commentmeta ( + comment_id TEXT NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES ('8', 'slash_test_2', 'foo')" ); + + $expected_value = 'String with 3 slashes ' . '\\'; + $mysql_literal_value = 'String with 3 slashes ' . '\\\\'; + $update = "UPDATE `wptests_commentmeta` SET `meta_value` = '{$mysql_literal_value}' WHERE `comment_id` = '8' AND `meta_key` = 'slash_test_2'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_commentmeta" SET "meta_value" = ' . $driver->get_connection()->quote( $expected_value ) . ' WHERE ("comment_id" = \'8\' AND "meta_key" = \'slash_test_2\') AND ("meta_value" IS DISTINCT FROM (' . $driver->get_connection()->quote( $expected_value ) . '))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_commentmeta WHERE comment_id = '8' AND meta_key = 'slash_test_2'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $expected_value, $rows[0]->meta_value ); + } + + /** + * Tests placeholder-like bytes in literal-only UPDATE statements remain data. + */ + public function test_simple_update_literal_placeholder_bytes_are_not_bound_parameters(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_commentmeta ( + comment_id TEXT NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES ('8', 'slash_test_2', 'foo')" ); + + $expected_value = 'literal ? :name ::text ' . '\\'; + $mysql_literal_value = 'literal ? :name ::text ' . '\\\\'; + $update = "UPDATE `wptests_commentmeta` SET `meta_value` = '{$mysql_literal_value}' WHERE `comment_id` = '8' AND `meta_key` = 'slash_test_2'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_commentmeta" SET "meta_value" = ' . $driver->get_connection()->quote( $expected_value ) . ' WHERE ("comment_id" = \'8\' AND "meta_key" = \'slash_test_2\') AND ("meta_value" IS DISTINCT FROM (' . $driver->get_connection()->quote( $expected_value ) . '))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_commentmeta WHERE comment_id = '8' AND meta_key = 'slash_test_2'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $expected_value, $rows[0]->meta_value ); + } + + /** + * Tests non-strict UPDATE coerces exact NULL assignments for NOT NULL columns. + */ + public function test_non_strict_update_null_coerces_not_null_columns_to_metadata_defaults(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_options_table_with_mysql_metadata( $driver ); + + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('cron', 'serialized', 'no')" ); + + $update = "UPDATE `wptests_options` SET `option_value` = NULL, `autoload` = NULL WHERE `option_name` = 'cron'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'UPDATE "wptests_options" SET "option_value" = \'\', "autoload" = \'yes\' WHERE ("option_name" = \'cron\') AND ("option_value" IS DISTINCT FROM (\'\') OR "autoload" IS DISTINCT FROM (\'yes\'))', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'cron'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests non-strict UPDATE normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_update_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03')" + ); + + $update = "UPDATE `wptests_posts` SET `post_date_gmt` = '2020-02-31 14:15:27', `post_modified_gmt` = '2020-07-04T01:02:03Z' WHERE `ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_posts" SET "post_date_gmt" = \'0000-00-00 00:00:00\', "post_modified_gmt" = \'2020-07-04 01:02:03\' WHERE ("ID" = 1) AND ("post_date_gmt" IS DISTINCT FROM (\'0000-00-00 00:00:00\') OR "post_modified_gmt" IS DISTINCT FROM (\'2020-07-04 01:02:03\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date_gmt, post_modified_gmt FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $posts ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date_gmt ); + $this->assertSame( '2020-07-04 01:02:03', $posts[0]->post_modified_gmt ); + } + + /** + * Tests strict SQL mode leaves UPDATE NULL assignments to fail visibly. + */ + public function test_strict_update_null_does_not_coerce_not_null_columns(): void { + $driver = $this->create_driver(); + $this->install_options_table_with_mysql_metadata( $driver ); + + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('cron', 'serialized', 'no')" ); + $driver->set_sql_mode( 'STRICT_ALL_TABLES' ); + + $this->expectException( PDOException::class ); + + $driver->query( "UPDATE `wptests_options` SET `option_value` = NULL WHERE `option_name` = 'cron'" ); + } + + /** + * Tests simple WordPress DELETE statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_delete_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'http://example.org')" ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('home', 'http://example.org')" ); + + $delete = "DELETE FROM `wp_options` WHERE `option_name` = 'siteurl'"; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wp_options" WHERE "option_name" = \'siteurl\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT option_name FROM wp_options ORDER BY option_name' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'home', $rows[0]->option_name ); + } + + /** + * Tests simple DELETE without WHERE is translated instead of passed through raw. + */ + public function test_simple_delete_without_where_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_all (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_all (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $delete = 'DELETE FROM wptests_delete_all'; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( 'DELETE FROM "wptests_delete_all"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT id FROM wptests_delete_all' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple DELETE modifiers are accepted as compatibility no-ops. + */ + public function test_simple_delete_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_modifiers (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_modifiers (id, value) VALUES (1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')" ); + + $queries = array( + 'DELETE LOW_PRIORITY FROM wptests_delete_modifiers WHERE id = 1' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 1', + 'DELETE QUICK FROM wptests_delete_modifiers WHERE id = 2' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 2', + 'DELETE IGNORE FROM wptests_delete_modifiers WHERE id = 3' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 3', + 'DELETE LOW_PRIORITY QUICK IGNORE FROM wptests_delete_modifiers WHERE id = 4' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 4', + ); + + foreach ( $queries as $query => $expected_sql ) { + $this->assertSame( 1, $driver->query( $query ), $query ); + $this->assertSame( $expected_sql, $this->get_last_single_postgresql_sql( $driver ), $query ); + } + + $rows = $driver->query( 'SELECT id FROM wptests_delete_modifiers' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple single-table DELETE aliases are translated to PostgreSQL aliases. + */ + public function test_simple_delete_with_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_alias (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_alias (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $delete = 'DELETE FROM `wptests_delete_alias` AS d WHERE d.id = 1'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + 'DELETE FROM "wptests_delete_alias" AS "d" WHERE d.id = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_delete_alias ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + } + + /** + * Tests simple DELETE ORDER BY without LIMIT uses a ctid subquery. + */ + public function test_simple_delete_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_ordered ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_delete_ordered (ctid, id, status) VALUES (1, 1, 'stale'), (2, 2, 'keep'), (3, 3, 'stale')" ); + + $delete = "DELETE FROM `wptests_delete_ordered` WHERE `status` = 'stale' ORDER BY `id` DESC"; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_delete_ordered" WHERE ctid IN (SELECT ctid FROM "wptests_delete_ordered" WHERE "status" = \'stale\' ORDER BY "id" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_delete_ordered ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'keep', $rows[0]->status ); + } + + /** + * Tests simple DELETE translates expression ORDER BY clauses without LIMIT. + */ + public function test_simple_delete_expression_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM wptests_delete_order_expression WHERE `status` = 'stale' ORDER BY LENGTH(`status`), `id` + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + $delete + ); + + $this->assertStringStartsWith( 'DELETE FROM "wptests_delete_order_expression" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_expression" WHERE "status" = \'stale\' ORDER BY ', $sql ); + $this->assertStringContainsString( 'OCTET_LENGTH(CONVERT_TO(CAST("status" AS text), \'UTF8\'))', $sql ); + $this->assertStringContainsString( ', "id" + 0 DESC)', $sql ); + } + + /** + * Tests bounded DELETE ORDER BY/LIMIT forms translate through PostgreSQL ctid. + */ + public function test_simple_delete_order_by_limit_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 1" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 1)', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 1, 2" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 2 OFFSET 1)', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 2 OFFSET 1" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 2 OFFSET 1)', + $sql + ); + } + + /** + * Tests bounded DELETE with multi-column ORDER BY and offset/count LIMIT. + */ + public function test_simple_delete_multi_column_order_by_limit_offset_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM wptests_delete_order_multi + WHERE state = 'stale' + ORDER BY priority ASC, id DESC + LIMIT 1, 2"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_order_multi" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_multi" WHERE state = \'stale\' ORDER BY priority ASC, id DESC LIMIT 2 OFFSET 1)', + $sql + ); + } + + /** + * Tests bounded DELETE ORDER BY/LIMIT offset,count deletes the intended ordered row slice. + */ + public function test_simple_delete_order_by_limit_offset_executes_expected_row_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_order_exec ( + ctid INTEGER PRIMARY KEY, + id INTEGER NOT NULL, + priority INTEGER NOT NULL, + state TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_delete_order_exec (ctid, id, priority, state) VALUES + (1, 1, 10, 'stale'), + (2, 2, 20, 'stale'), + (3, 3, 30, 'stale'), + (4, 4, 40, 'stale'), + (5, 5, 50, 'keep')" + ); + + $delete = "DELETE FROM wptests_delete_order_exec + WHERE state = 'stale' + ORDER BY priority ASC, id ASC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( + 'DELETE FROM "wptests_delete_order_exec" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_exec" WHERE state = \'stale\' ORDER BY priority ASC, id ASC LIMIT 2 OFFSET 1)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, state FROM wptests_delete_order_exec ORDER BY id' ); + + $this->assertCount( 3, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'stale', $rows[0]->state ); + $this->assertSame( '4', $rows[1]->id ); + $this->assertSame( 'stale', $rows[1]->state ); + $this->assertSame( '5', $rows[2]->id ); + $this->assertSame( 'keep', $rows[2]->state ); + } + + /** + * Tests simple DELETE LIMIT without WHERE or ORDER BY uses a ctid subquery. + */ + public function test_simple_delete_limit_without_where_or_order_by_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + 'DELETE FROM wptests_delete_limit_only LIMIT 1, 2' + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limit_only" WHERE ctid IN (SELECT ctid FROM "wptests_delete_limit_only" LIMIT 2 OFFSET 1)', + $sql + ); + + $driver->query( + 'CREATE TABLE wptests_delete_limit_only ( + ctid INTEGER PRIMARY KEY, + id INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_delete_limit_only (ctid, id) VALUES (1, 1), (2, 2), (3, 3), (4, 4)' ); + + $this->assertSame( 2, $driver->query( 'DELETE FROM wptests_delete_limit_only LIMIT 1, 2' ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS remaining FROM wptests_delete_limit_only' ); + $this->assertSame( '2', $rows[0]->remaining ); + } + + /** + * Tests MySQL multi-target DELETE statements translate to PostgreSQL writable CTEs. + */ + public function test_mysql_multi_target_delete_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_child" AS "c"', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) + (SELECT COUNT(*) FROM mysql_delete_target_1) AS affected_rows', $sql ); + + $ordered_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + ORDER BY LENGTH(c.reason), p.id DESC"; + + $ordered_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $ordered_delete + ); + + $this->assertNotNull( $ordered_sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $ordered_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' ORDER BY CASE WHEN ", $ordered_sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(c.reason AS text), 'UTF8')) END, p.id DESC", $ordered_sql ); + + $limited_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + ORDER BY c.reason ASC, p.id DESC + LIMIT 1, 2"; + + $limited_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $limited_delete + ); + + $this->assertNotNull( $limited_sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $limited_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' ORDER BY c.reason ASC, p.id DESC LIMIT 2 OFFSET 1", $limited_sql ); + + $limit_only_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + LIMIT 2"; + + $limit_only_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $limit_only_delete + ); + + $this->assertNotNull( $limit_only_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' LIMIT 2", $limit_only_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $limit_only_sql ); + } + + /** + * Tests MySQL multi-target DELETE modifiers are accepted as compatibility no-ops. + */ + public function test_mysql_multi_target_delete_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $delete = "DELETE LOW_PRIORITY QUICK IGNORE p, c + FROM wptests_delete_multi_modifier_parent AS p + JOIN wptests_delete_multi_modifier_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_multi_modifier_parent AS p JOIN wptests_delete_multi_modifier_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_modifier_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_modifier_child" AS "c"', $sql ); + $this->assertStringNotContainsString( 'LOW_PRIORITY', $sql ); + $this->assertStringNotContainsString( 'QUICK', $sql ); + $this->assertStringNotContainsString( 'IGNORE', $sql ); + } + + /** + * Tests single-target joined DELETE without WHERE uses the target-list rewrite. + */ + public function test_mysql_single_target_join_delete_without_where_is_translated_to_writable_cte(): void { + $driver = $this->create_driver(); + + $delete = 'DELETE p + FROM wptests_delete_join_parent AS p + JOIN wptests_delete_join_child AS c ON c.parent_id = p.id'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_join_parent AS p JOIN wptests_delete_join_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringNotContainsString( 'ON c.parent_id = p.id WHERE', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_join_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) AS affected_rows', $sql ); + } + + /** + * Tests MySQL multi-target DELETE USING statements share the writable CTE path. + */ + public function test_mysql_multi_target_delete_using_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM p, c + USING wptests_delete_using_parent AS p + JOIN wptests_delete_using_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_using_parent AS p JOIN wptests_delete_using_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_child" AS "c"', $sql ); + } + + /** + * Tests MySQL FROM ... USING multi-target DELETE modifiers are accepted. + */ + public function test_mysql_multi_target_delete_using_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $delete = "DELETE LOW_PRIORITY QUICK IGNORE FROM p, c + USING wptests_delete_using_modifier_parent AS p + JOIN wptests_delete_using_modifier_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_using_modifier_parent AS p JOIN wptests_delete_using_modifier_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_modifier_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_modifier_child" AS "c"', $sql ); + $this->assertStringNotContainsString( 'LOW_PRIORITY', $sql ); + $this->assertStringNotContainsString( 'QUICK', $sql ); + $this->assertStringNotContainsString( 'IGNORE', $sql ); + } + + /** + * Tests same-table multi-target DELETE statements delete each physical table once. + */ + public function test_mysql_same_table_multi_target_delete_groups_physical_targets(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM a, b + USING wptests_delete_same AS a, wptests_delete_same AS b + WHERE a.option_name = CONCAT('_transient_', b.option_name)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "a".ctid AS "mysql_delete_target_0_ctid", "b".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_same" AS "a" USING mysql_delete_rows', $sql ); + $this->assertStringContainsString( + 'WHERE "a".ctid IN (SELECT "mysql_delete_target_0_ctid" FROM mysql_delete_rows UNION SELECT "mysql_delete_target_1_ctid" FROM mysql_delete_rows) RETURNING 1', + $sql + ); + $this->assertSame( 1, substr_count( $sql, 'DELETE FROM "wptests_delete_same"' ) ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) AS affected_rows', $sql ); + } + + /** + * Tests generic multi-target DELETE predicates support common MySQL expressions. + */ + public function test_mysql_multi_target_delete_supports_complex_predicates(): void { + $driver = $this->create_driver(); + + $delete = "DELETE p, c + FROM wptests_delete_complex_parent AS p + JOIN wptests_delete_complex_child AS c ON c.parent_id = p.id + WHERE (p.status LIKE 'stale%' OR c.reason = CONCAT('old_', SUBSTRING(p.status, 1, 5))) + AND c.note NOT LIKE 'keep%'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( "p.status LIKE 'stale%'", $sql ); + $this->assertStringContainsString( "CAST('old_' AS text) || CAST(", $sql ); + $this->assertStringContainsString( 'SUBSTRING(CAST(p.status AS text)', $sql ); + $this->assertStringContainsString( "c.note NOT LIKE 'keep%'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_complex_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_complex_child" AS "c"', $sql ); + } + + /** + * Tests joined DELETE statements can read from information_schema sources. + */ + public function test_mysql_delete_joined_to_information_schema_is_translated(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema = DATABASE()'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "o".ctid AS "mysql_delete_target_0_ctid"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o"', $sql ); + } + + /** + * Tests information_schema aliases remain read-only in joined DELETE statements. + */ + public function test_mysql_delete_joined_to_information_schema_rejects_catalog_target(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE it + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name'; + + try { + $driver->query( $delete ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests bare uppercase ID in simple DELETE WHERE clauses is quoted. + */ + public function test_simple_delete_with_bare_uppercase_id_where_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'editor\')' ); + + $delete = 'DELETE FROM wptests_users WHERE ID != 1'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_users" WHERE "ID" != 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WooCommerce orphan cleanup DELETE statements are translated to anti-joins. + */ + public function test_mysql_left_join_orphan_delete_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY, + post_id INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_posts ("ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (1, 1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (2, 999)' ); + + $delete = 'DELETE meta FROM wptests_postmeta meta LEFT JOIN wptests_posts posts ON posts.ID = meta.post_id WHERE posts.ID IS NULL;'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS meta WHERE NOT EXISTS (SELECT 1 FROM "wptests_posts" AS posts WHERE posts."ID" = meta.post_id)', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT meta_id, post_id FROM wptests_postmeta ORDER BY meta_id' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->meta_id ); + $this->assertSame( '1', $rows[0]->post_id ); + } + + /** + * Tests MySQL joined DELETE statements with AS aliases are translated. + */ + public function test_mysql_join_delete_with_as_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE `postmeta` FROM `wptests_postmeta` AS `postmeta` + LEFT JOIN `wptests_posts` AS `posts` ON `posts`.`ID` = `postmeta`.`post_id` + WHERE `posts`.`post_type` = 'forum' + AND `postmeta`.`meta_key` = '_bbp_reply_count' + OR `postmeta`.`meta_key` = '_bbp_total_reply_count'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS "postmeta" WHERE "postmeta".ctid IN (SELECT "postmeta".ctid FROM "wptests_postmeta" AS "postmeta" LEFT JOIN "wptests_posts" AS "posts" ON "posts"."ID" = "postmeta"."post_id" WHERE "posts"."post_type" = \'forum\' AND "postmeta"."meta_key" = \'_bbp_reply_count\' OR "postmeta"."meta_key" = \'_bbp_total_reply_count\')', + $sql + ); + } + + /** + * Tests joined DELETE REGEXP predicates are translated to PostgreSQL regex operators. + */ + public function test_mysql_join_delete_regexp_predicate_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_regexp d + JOIN wptests_delete_regexp_related r ON r.id = d.related_id + WHERE d.name REGEXP '^x' AND r.status = 'old'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_regexp" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_regexp d JOIN wptests_delete_regexp_related r ON r.id = d.related_id WHERE d.name ~* \'^x\' AND r.status = \'old\')', + $sql + ); + } + + /** + * Tests joined DELETE ORDER BY/LIMIT clauses are applied inside the ctid subquery. + */ + public function test_mysql_join_delete_order_by_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id DESC + LIMIT 1"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id DESC LIMIT 1)', + $sql + ); + } + + /** + * Tests joined DELETE ORDER BY without LIMIT is preserved in the ctid subquery. + */ + public function test_mysql_join_delete_order_by_without_limit_preserves_ordering(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_ordered d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_ordered" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_ordered d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id DESC)', + $sql + ); + } + + /** + * Tests joined DELETE expression ORDER BY without LIMIT is translated. + */ + public function test_mysql_join_delete_expression_order_by_without_limit_is_translated(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_order_expression d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY LENGTH(d.status), d.id + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertStringStartsWith( + 'DELETE FROM "wptests_delete_order_expression" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_order_expression d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY ', + $sql + ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(d.status AS text), \'UTF8\')) END, d.id + 0 DESC)', $sql ); + } + + /** + * Tests joined DELETE supports MySQL LIMIT offset,count syntax. + */ + public function test_mysql_join_delete_limit_offsets_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id ASC + LIMIT 2, 3"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id ASC LIMIT 3 OFFSET 2)', + $sql + ); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id ASC + LIMIT 3 OFFSET 2"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id ASC LIMIT 3 OFFSET 2)', + $sql + ); + } + + /** + * Tests joined DELETE statements can read from information_schema sources. + */ + public function test_joined_delete_can_read_information_schema_sources(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = "DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema = DATABASE() + AND o.autoload = 'yes'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o" WHERE "o".ctid IN (SELECT "o".ctid FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( '"o"."autoload" = \'yes\'', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests information_schema joined DELETE predicates can use nested current-database SELECTs. + */ + public function test_joined_delete_can_read_information_schema_predicate_subqueries(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema IN (SELECT DATABASE())'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o" WHERE "o".ctid IN (SELECT "o".ctid FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" IN ( SELECT \'wptests\' )', $sql ); + $this->assertStringNotContainsString( 'DATABASE()', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests unsupported information_schema joined DELETE predicates fail before backend execution. + */ + public function test_joined_delete_rejects_unsupported_information_schema_predicates(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema IN (SELECT DATABASE() UNION SELECT DATABASE())'; + + try { + $driver->query( $delete ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported DELETE shapes fail before backend execution. + */ + public function test_unsupported_delete_shapes_fail_closed_before_backend(): void { + $queries = array( + 'DELETE FROM wptests_delete_order_bad + ORDER BY missing_alias.id', + 'DELETE FROM wptests_delete_order_bad + ORDER BY id + LIMIT bad', + 'DELETE FROM wptests_delete_order_bad + ORDER BY id + LIMIT 1, bad', + "DELETE d, r FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id LIMIT bad", + "DELETE d FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY missing_alias.id", + "DELETE d, r FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY missing_alias.id", + "DELETE d FROM other_db.wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests MySQL DUAL table references are erased in PostgreSQL-compatible SELECTs. + */ + public function test_mysql_dual_table_reference_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT 1 AS output FROM DUAL' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->output ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT 1 AS output', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL DUAL table references are erased in INSERT ... SELECT queries. + */ + public function test_insert_select_from_dual_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_actionscheduler_actions ( + hook TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_actionscheduler_actions (`hook`, `status`) + SELECT 'action_scheduler/migration_hook', 'pending' FROM DUAL + WHERE ( SELECT NULL FROM DUAL ) IS NULL"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO wptests_actionscheduler_actions ("hook", "status") SELECT \'action_scheduler/migration_hook\', \'pending\' WHERE (SELECT NULL) IS NULL', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT hook, status FROM wptests_actionscheduler_actions' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'action_scheduler/migration_hook', $rows[0]->hook ); + $this->assertSame( 'pending', $rows[0]->status ); + } + + /** + * Tests 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, CHAR/BINARY) expressions are translated to PostgreSQL. + */ + public function test_convert_char_and_binary_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT CONVERT('abc', CHAR) AS char_value, CONVERT('abc', BINARY) AS binary_value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'abc', $rows[0]->char_value ); + $this->assertSame( 'abc', $rows[0]->binary_value ); + $this->assertSame( + "SELECT CAST('abc' AS text) AS char_value, CAST('abc' AS text) AS binary_value", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests CONVERT(expr, CHAR/BINARY) expressions translate in predicates and ordering. + */ + public function test_convert_char_and_binary_column_references_translate_in_predicates_and_ordering(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_convert_typed (value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_convert_typed (value) VALUES ('abc')" ); + $driver->query( "INSERT INTO wptests_convert_typed (value) VALUES ('ABC')" ); + + $rows = $driver->query( + "SELECT CONVERT(value, CHAR) AS value_text + FROM wptests_convert_typed + WHERE CONVERT(value, BINARY) = 'abc' + ORDER BY CONVERT(value, CHAR)" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'abc', $rows[0]->value_text ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT CAST(value AS text) AS value_text', $sql ); + $this->assertStringContainsString( "WHERE CAST(value AS text) = 'abc'", $sql ); + $this->assertStringContainsString( 'ORDER BY CAST(value AS text)', $sql ); + $this->assertStringNotContainsString( 'CONVERT', $sql ); + } + + /** + * Tests CAST(expr AS DATE) expressions use explicit PostgreSQL semantics. + */ + public function test_cast_date_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $date_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CAST('2025-10-05 14:05:28' AS DATE) AS date_value" + ); + + $this->assertNotNull( $date_sql ); + $this->assertStringContainsString( 'TO_CHAR', $date_sql ); + $this->assertStringContainsString( ' AS date_value', $date_sql ); + $this->assertStringNotContainsString( 'CAST(' . "'2025-10-05 14:05:28'" . ' AS DATE)', $date_sql ); + } + + /** + * Tests unsupported CONVERT(expr, type) forms fail before backend execution. + */ + public function test_unsupported_convert_typed_forms_fail_closed_before_backend_execution(): void { + $queries = array( + "SELECT CONVERT('12:34:56', TIME) AS time_value", + "SELECT CONVERT('2025-10-05 14:05:28', DATETIME) AS datetime_value", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CONVERT() runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests MySQL FIELD() expressions are translated for PostgreSQL ordering. + */ + public function test_field_function_is_translated_to_postgresql_case_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_name) VALUES (1, \'alpha\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_name) VALUES (2, \'beta\')' ); + + $select = 'SELECT ID FROM wptests_posts WHERE ID IN (1, 2) ORDER BY FIELD(ID, 2, 1)'; + $rows = $driver->query( $select ); + + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( '1', $rows[1]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID" FROM wptests_posts WHERE "ID" IN (1, 2) ORDER BY CASE WHEN "ID" IS NULL THEN 0 WHEN CAST("ID" AS text) = CAST(2 AS text) THEN 1 WHEN CAST("ID" AS text) = CAST(1 AS text) THEN 2 ELSE 0 END', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests lowercase field() calls trigger PostgreSQL compatibility translation. + */ + public function test_lowercase_field_function_triggers_postgresql_rewrite(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (post_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts (post_name) VALUES (\'alpha\')' ); + $driver->query( 'INSERT INTO wptests_posts (post_name) VALUES (\'beta\')' ); + + $rows = $driver->query( "SELECT post_name FROM wptests_posts ORDER BY field(post_name, 'beta', 'alpha')" ); + + $this->assertSame( 'beta', $rows[0]->post_name ); + $this->assertSame( 'alpha', $rows[1]->post_name ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_name FROM wptests_posts ORDER BY CASE WHEN post_name IS NULL THEN 0 WHEN CAST(post_name AS text) = CAST(\'beta\' AS text) THEN 1 WHEN CAST(post_name AS text) = CAST(\'alpha\' AS text) THEN 2 ELSE 0 END', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests FIELD() returns zero for NULL and missing values. + */ + public function test_field_function_returns_zero_for_null_and_missing_values(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT FIELD(NULL, 1) AS null_position, FIELD('missing', 'alpha') AS missing_position, FIELD('alpha', 'beta', 'alpha') AS alpha_position" ); + + $this->assertSame( '0', $rows[0]->null_position ); + $this->assertSame( '0', $rows[0]->missing_position ); + $this->assertSame( '2', $rows[0]->alpha_position ); + } + + /** + * Tests SELECT VERSION() matches the emulated MySQL version and output label. + */ + public function test_version_runtime_function_matches_emulated_mysql_version(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT VERSION()' ); + + $this->assertSame( '8.0.38', $rows[0]->{'VERSION()'} ); + $this->assertSame( array( 'VERSION()' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT \'8.0.38\' AS "VERSION()"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL session runtime functions use the emulated session identity. + */ + public function test_mysql_session_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + 'SELECT CURRENT_USER AS current_user_bare, + CURRENT_USER() AS current_user_call, + USER() AS user_value, + SESSION_USER() AS session_user_value, + SYSTEM_USER() AS system_user_value, + CONNECTION_ID() AS connection_id, + LAST_INSERT_ID() AS last_insert_id' + ); + + $this->assertSame( 'root@%', $rows[0]->current_user_bare ); + $this->assertSame( 'root@%', $rows[0]->current_user_call ); + $this->assertSame( 'root@%', $rows[0]->user_value ); + $this->assertSame( 'root@%', $rows[0]->session_user_value ); + $this->assertSame( 'root@%', $rows[0]->system_user_value ); + $this->assertSame( '1', $rows[0]->connection_id ); + $this->assertSame( '0', $rows[0]->last_insert_id ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "'root@%' AS current_user_bare", $sql ); + $this->assertStringContainsString( "'root@%' AS current_user_call", $sql ); + $this->assertStringContainsString( "'root@%' AS user_value", $sql ); + $this->assertStringContainsString( "'root@%' AS session_user_value", $sql ); + $this->assertStringContainsString( "'root@%' AS system_user_value", $sql ); + $this->assertStringContainsString( '1 AS connection_id', $sql ); + $this->assertStringContainsString( '0 AS last_insert_id', $sql ); + } + + /** + * Tests LAST_INSERT_ID() reflects mutable session state instead of cached SQL. + */ + public function test_last_insert_id_runtime_function_is_not_cached_between_inserts(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_runtime_insert_id ( + id bigint(20) unsigned NOT NULL auto_increment, + value varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $driver->query( "INSERT INTO wptests_runtime_insert_id (value) VALUES ('first')" ); + $first = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $driver->query( "INSERT INTO wptests_runtime_insert_id (value) VALUES ('second')" ); + $second = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '1', $first[0]->last_insert_id ); + $this->assertSame( '2', $second[0]->last_insert_id ); + $this->assertSame( 'SELECT 2 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID(expr) sets mutable session state for safe standalone integer literals. + */ + public function test_last_insert_id_assignment_runtime_function_sets_and_reads_back(): void { + $driver = $this->create_driver(); + + $assigned = $driver->query( 'SELECT LAST_INSERT_ID(123) AS assigned_id' ); + + $this->assertSame( '123', $assigned[0]->assigned_id ); + $this->assertSame( 123, $driver->get_insert_id() ); + $this->assertSame( 'SELECT 123 AS assigned_id', $this->get_last_single_postgresql_sql( $driver ) ); + + $readback = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '123', $readback[0]->last_insert_id ); + $this->assertSame( 'SELECT 123 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID() readbacks are not cached across LAST_INSERT_ID(expr) assignments. + */ + public function test_last_insert_id_assignment_runtime_function_invalidates_readback_cache(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(10) AS assigned_id' ); + $first = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $driver->query( 'SELECT LAST_INSERT_ID(20) AS assigned_id' ); + $second = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '10', $first[0]->last_insert_id ); + $this->assertSame( '20', $second[0]->last_insert_id ); + $this->assertSame( 20, $driver->get_insert_id() ); + $this->assertSame( 'SELECT 20 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID(expr) projections preserve aliases and left-to-right session reads. + */ + public function test_last_insert_id_assignment_runtime_function_preserves_projection_order_and_aliases(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(7) AS seed_id' ); + + $rows = $driver->query( + 'SELECT LAST_INSERT_ID() AS before_id, + LAST_INSERT_ID(456) AS assigned_id, + LAST_INSERT_ID() AS after_id, + 9 AS literal_value' + ); + + $this->assertSame( '7', $rows[0]->before_id ); + $this->assertSame( '456', $rows[0]->assigned_id ); + $this->assertSame( '456', $rows[0]->after_id ); + $this->assertSame( '9', $rows[0]->literal_value ); + $this->assertSame( 456, $driver->get_insert_id() ); + $this->assertSame( + array( 'before_id', 'assigned_id', 'after_id', 'literal_value' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( + 'SELECT 7 AS before_id, 456 AS assigned_id, 456 AS after_id, 9 AS literal_value', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) SELECT forms fail before backend execution. + */ + public function test_last_insert_id_assignment_runtime_function_unsupported_forms_fail_closed(): void { + $queries = array( + "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id", + 'SELECT LAST_INSERT_ID(123 + 1) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(id) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(-1) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(18446744073709551615) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(123) + 1 AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(123) AS invalid_last_insert_id FROM runtime_names', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LAST_INSERT_ID(expr) runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) SELECT forms do not mutate session state. + */ + public function test_last_insert_id_assignment_runtime_function_unsupported_forms_do_not_mutate_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(321) AS assigned_id' ); + + try { + $driver->query( "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id" ); + $this->fail( 'Expected unsupported LAST_INSERT_ID(expr) runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage() ); + } + + $this->assertSame( 321, $driver->get_insert_id() ); + + $readback = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + $this->assertSame( '321', $readback[0]->last_insert_id ); + } + + /** + * Tests ROW_COUNT() reflects mutable last-result state instead of cached SQL. + */ + public function test_row_count_runtime_function_tracks_last_result_and_is_not_cached(): void { + $driver = $this->create_driver(); + + $initial = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '0', $initial[0]->row_count ); + $this->assertSame( 'SELECT 0 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'CREATE TABLE wptests_runtime_row_count (id INTEGER PRIMARY KEY, value TEXT)' ); + $driver->query( "INSERT INTO wptests_runtime_row_count (id, value) VALUES (1, 'first'), (2, 'second')" ); + $after_insert = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '2', $after_insert[0]->row_count ); + $this->assertSame( 'SELECT 2 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( "UPDATE wptests_runtime_row_count SET value = 'updated' WHERE id = 1" ); + $after_update = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '1', $after_update[0]->row_count ); + $this->assertSame( 'SELECT 1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'DELETE FROM wptests_runtime_row_count WHERE id = 99' ); + $after_delete = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '0', $after_delete[0]->row_count ); + $this->assertSame( 'SELECT 0 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'SELECT id FROM wptests_runtime_row_count WHERE id = 1' ); + $after_result_set = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '-1', $after_result_set[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests ROW_COUNT() reflects failed statement state. + */ + public function test_row_count_runtime_function_tracks_failed_statement_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_runtime_row_count_failure (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_runtime_row_count_failure (id, value) VALUES (1, 'first'), (2, 'second')" ); + + $after_insert = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '2', $after_insert[0]->row_count ); + + try { + $driver->query( 'INSERT INTO wptests_runtime_row_count_failure (id, value) VALUES (3, NULL)' ); + $this->fail( 'Expected backend insert failure.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'NOT NULL', strtoupper( $e->getMessage() ) ); + } + + $after_backend_failure = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '-1', $after_backend_failure[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + try { + $driver->query( 'SELECT ROW_COUNT(123) AS invalid_row_count' ); + $this->fail( 'Expected unsupported ROW_COUNT() form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage() ); + } + + $after_unsupported_failure = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '-1', $after_unsupported_failure[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests table-driven simple function rewrites preserve runtime behavior. + */ + public function test_simple_common_function_rewrite_descriptors_preserve_runtime_behavior(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + "SELECT + MD5('abc') AS md5_hash, + LOWER('ABC') AS lower_value, + UPPER('abc') AS upper_value, + IFNULL(NULL, 'fallback') AS ifnull_value, + NULLIF('same', 'same') AS nullif_value, + COALESCE(NULL, 'first', 'second') AS coalesce_value, + CONCAT('wp', '_', 'db') AS concat_value, + CHAR_LENGTH('hello') AS char_length_value, + LOCATE('or', 'WordPress') AS locate_value, + INSTR('WordPress', 'Press') AS instr_value, + REVERSE('desserts') AS reverse_value, + LTRIM(' left') AS ltrim_value, + RTRIM('right ') AS rtrim_value, + REPLACE('banana', 'na', 'NA') AS replace_value, + ISNULL(NULL) AS isnull_value" + ); + + $this->assertSame( '900150983cd24fb0d6963f7d28e17f72', $rows[0]->md5_hash ); + $this->assertSame( 'abc', $rows[0]->lower_value ); + $this->assertSame( 'ABC', $rows[0]->upper_value ); + $this->assertSame( 'fallback', $rows[0]->ifnull_value ); + $this->assertNull( $rows[0]->nullif_value ); + $this->assertSame( 'first', $rows[0]->coalesce_value ); + $this->assertSame( 'wp_db', $rows[0]->concat_value ); + $this->assertSame( '5', $rows[0]->char_length_value ); + $this->assertSame( '2', $rows[0]->locate_value ); + $this->assertSame( '5', $rows[0]->instr_value ); + $this->assertSame( 'stressed', $rows[0]->reverse_value ); + $this->assertSame( 'left', $rows[0]->ltrim_value ); + $this->assertSame( 'right', $rows[0]->rtrim_value ); + $this->assertSame( 'baNANA', $rows[0]->replace_value ); + $this->assertSame( '1', $rows[0]->isnull_value ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "COALESCE(NULL, 'fallback') AS ifnull_value", $sql ); + $this->assertStringContainsString( "(CAST('wp' AS text) || CAST('_' AS text) || CAST('db' AS text)) AS concat_value", $sql ); + $this->assertStringContainsString( "STRPOS(CAST('WordPress' AS text), CAST('or' AS text)) AS locate_value", $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + } + + /** + * Tests nested table-driven simple function rewrites share the recursive translator. + */ + public function test_nested_simple_common_function_rewrite_descriptors_translate_recursively(): void { + $driver = $this->create_backendless_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT LOWER(IFNULL(NULL, CONCAT('WP', '_', MD5('abc')))) AS nested_value" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'LOWER(CAST(COALESCE(NULL,', $sql ); + $this->assertStringContainsString( "CAST('WP' AS text) || CAST('_' AS text) || CAST(MD5(CAST('abc' AS text)) AS text)", $sql ); + $this->assertStringContainsString( 'AS nested_value', $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + } + + /** + * Tests unsupported table-driven simple function forms fail before backend execution. + */ + public function test_simple_common_function_rewrite_descriptors_reject_unsupported_forms_before_backend_execution(): void { + $queries = array( + 'SELECT LOWER() AS value', + "SELECT LOWER('a', 'b') AS value", + 'SELECT IFNULL(NULL) AS value', + 'SELECT CONCAT() AS value', + 'SELECT COALESCE() AS value', + "SELECT LOCATE('needle', 'haystack', 1, 2) AS value", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported simple runtime function form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests common MySQL runtime functions from the SQLite compatibility layer are translated. + */ + public function test_common_mysql_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT MD5('abc') AS md5_hash, + ASCII('Az') AS ascii_value, + ASCII('') AS ascii_empty_value, + LEFT('Lorem ipsum', 5) AS left_part, + UCASE('abc') AS upper_part, + LCASE('ABC') AS lower_part, + LTRIM(' left') AS ltrim_part, + RTRIM('right ') AS rtrim_part, + TRIM(' both ') AS trim_part, + TRIM(LEADING FROM ' leading') AS trim_leading_default_part, + TRIM(TRAILING FROM 'trailing ') AS trim_trailing_default_part, + TRIM(BOTH FROM ' both ') AS trim_both_default_part, + TRIM('xy' FROM 'xyxytrimxy') AS trim_literal_part, + TRIM(LEADING 'xy' FROM 'xyxytrim') AS trim_leading_literal_part, + TRIM(TRAILING 'xy' FROM 'trimxyxy') AS trim_trailing_literal_part, + TRIM(LEADING fallback_value FROM primary_value) AS trim_dynamic_leading_part, + TRIM(fallback_value FROM primary_value) AS trim_dynamic_both_part, + ISNULL(NULL) AS is_null_value, + IS_UUID('12345678-1234-5678-1234-567812345678') AS is_uuid_dashed_value, + IS_UUID('{12345678-1234-5678-1234-567812345678}') AS is_uuid_braced_value, + IS_UUID('12345678123456781234567812345678') AS is_uuid_compact_value, + IS_UUID('not-a-uuid') AS is_uuid_invalid_value, + IS_UUID(NULL) AS is_uuid_null_value, + IF(1, 'yes', 'no') AS if_numeric, + IF(1 = 0, 'yes', 'no') AS if_predicate, + IFNULL(NULL, 'fallback') AS ifnull_value, + NULLIF('same', 'same') AS nullif_value, + COALESCE(NULL, 'first', 'second') AS coalesce_value, + CONCAT('wp', '_', 'db') AS concat_value, + CONCAT_WS('-', 'wp', NULL, 'db') AS concat_ws_value, + ELT(2, 'first', 'second', 'third') AS elt_value, + MAKE_SET(9, 'read', 'write', 'delete', 'execute') AS make_set_value, + MAKE_SET(0, 'read') AS make_set_empty_value, + MAKE_SET(NULL, 'read') AS make_set_null_value, + CHAR_LENGTH('hello') AS char_length_value, + CHARACTER_LENGTH('hello') AS character_length_value, + SUBSTRING('abcdef', 2, 3) AS substring_value, + SUBSTRING('abcdef' FROM 2 FOR 3) AS substring_from_value, + SUBSTR('abcdef', 4) AS substr_value, + MID('abcdef', 2, 2) AS mid_value, + REPLACE('banana', 'na', 'NA') AS replace_value, + LEAST(3, 9, 4) AS least_value, + GREATEST(3, 9, 4) AS greatest_value, + LOG(8) AS natural_log_value, + LOG(2, 8) AS based_log_value, + DATEDIFF('2024-01-05', '2024-01-02') AS day_diff, + DAYNAME('2024-06-16') AS day_name_value, + MONTHNAME('2024-06-16') AS month_name_value, + LENGTH('hello') AS byte_length, + LOCATE('or', 'WordPress') AS locate_value, + LOCATE('r', 'WordPress', 4) AS locate_with_position, + INSTR('WordPress', 'Press') AS instr_value, + FIND_IN_SET('Press', 'Word,Press,SQLite') AS find_in_set_value, + FIND_IN_SET('with,comma', 'with,comma') AS find_in_set_comma_value, + FIND_IN_SET(NULL, 'Word,Press') AS find_in_set_null_value, + HEX('Az') AS hex_value, + UNHEX('417a') AS unhex_value, + RIGHT('WordPress', 5) AS right_part, + LPAD('42', 5, '0') AS lpad_value, + RPAD('42', 5, '0') AS rpad_value, + REPEAT('ab', 3) AS repeat_value, + REVERSE('desserts') AS reverse_value, + SPACE(3) AS space_value, + TO_BASE64('wp') AS base64_value, + FROM_BASE64('d3A=') AS base64_decoded, + INET_ATON('127.0.0.1') AS inet_number, + INET_NTOA(2130706433) AS inet_address, + FROM_UNIXTIME(0) AS epoch_datetime, + FROM_UNIXTIME(0, '%Y') AS epoch_year, + UNIX_TIMESTAMP('1970-01-02 00:00:00') AS epoch_seconds, + UUID() AS uuid_value"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( "MD5(CAST('abc' AS text)) AS md5_hash", $sql ); + $this->assertStringContainsString( "WHEN CAST('Az' AS text) = '' THEN 0 ELSE GET_BYTE(CONVERT_TO(CAST('Az' AS text), 'UTF8'), 0) END AS ascii_value", $sql ); + $this->assertStringContainsString( "WHEN CAST('' AS text) = '' THEN 0 ELSE GET_BYTE(CONVERT_TO(CAST('' AS text), 'UTF8'), 0) END AS ascii_empty_value", $sql ); + $this->assertStringContainsString( "LEFT(CAST('Lorem ipsum' AS text), CAST(5 AS integer)) AS left_part", $sql ); + $this->assertStringContainsString( "UPPER(CAST('abc' AS text)) AS upper_part", $sql ); + $this->assertStringContainsString( "LOWER(CAST('ABC' AS text)) AS lower_part", $sql ); + $this->assertStringContainsString( "LTRIM(CAST(' left' AS text), ' ') AS ltrim_part", $sql ); + $this->assertStringContainsString( "RTRIM(CAST('right ' AS text), ' ') AS rtrim_part", $sql ); + $this->assertStringContainsString( "BTRIM(CAST(' both ' AS text), ' ') AS trim_part", $sql ); + $this->assertStringContainsString( "LTRIM(CAST(' leading' AS text), ' ') AS trim_leading_default_part", $sql ); + $this->assertStringContainsString( "RTRIM(CAST('trailing ' AS text), ' ') AS trim_trailing_default_part", $sql ); + $this->assertStringContainsString( "BTRIM(CAST(' both ' AS text), ' ') AS trim_both_default_part", $sql ); + $this->assertStringContainsString( "REGEXP_REPLACE(REGEXP_REPLACE(CAST('xyxytrimxy' AS text), '^(xy)+', ''), '(xy)+$', '') AS trim_literal_part", $sql ); + $this->assertStringContainsString( "REGEXP_REPLACE(CAST('xyxytrim' AS text), '^(xy)+', '') AS trim_leading_literal_part", $sql ); + $this->assertStringContainsString( "REGEXP_REPLACE(CAST('trimxyxy' AS text), '(xy)+$', '') AS trim_trailing_literal_part", $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(primary_value AS text) IS NULL OR CAST(fallback_value AS text) IS NULL THEN NULL', $sql ); + $this->assertStringContainsString( "REGEXP_REPLACE(CAST(fallback_value AS text), '([\\\\.^$|?*+()[\\]{}])'", $sql ); + $this->assertStringContainsString( 'AS trim_dynamic_leading_part', $sql ); + $this->assertStringContainsString( 'AS trim_dynamic_both_part', $sql ); + $this->assertStringContainsString( 'CASE WHEN NULL IS NULL THEN 1 ELSE 0 END AS is_null_value', $sql ); + $this->assertStringContainsString( "CAST('12345678-1234-5678-1234-567812345678' AS text) ~*", $sql ); + $this->assertStringContainsString( 'AS is_uuid_dashed_value', $sql ); + $this->assertStringContainsString( 'AS is_uuid_braced_value', $sql ); + $this->assertStringContainsString( 'AS is_uuid_compact_value', $sql ); + $this->assertStringContainsString( 'AS is_uuid_invalid_value', $sql ); + $this->assertStringContainsString( 'CAST(NULL AS text) IS NULL THEN NULL', $sql ); + $this->assertStringContainsString( 'AS is_uuid_null_value', $sql ); + $this->assertStringContainsString( "THEN 'yes' ELSE 'no' END AS if_numeric", $sql ); + $this->assertStringContainsString( "CASE WHEN (1 = 0) THEN 'yes' ELSE 'no' END AS if_predicate", $sql ); + $this->assertStringContainsString( "COALESCE(NULL, 'fallback') AS ifnull_value", $sql ); + $this->assertStringContainsString( "NULLIF('same', 'same') AS nullif_value", $sql ); + $this->assertStringContainsString( "COALESCE(NULL, 'first', 'second') AS coalesce_value", $sql ); + $this->assertStringContainsString( "(CAST('wp' AS text) || CAST('_' AS text) || CAST('db' AS text)) AS concat_value", $sql ); + $this->assertStringContainsString( "CASE WHEN '-' IS NULL THEN NULL ELSE", $sql ); + $this->assertStringContainsString( "COALESCE(CAST('wp' AS text), '')", $sql ); + $this->assertStringContainsString( "THEN CAST('-' AS text) ELSE '' END", $sql ); + $this->assertStringContainsString( "COALESCE(CAST('db' AS text), '')", $sql ); + $this->assertStringContainsString( 'END AS concat_ws_value', $sql ); + $this->assertStringContainsString( "THEN CAST('second' AS text)", $sql ); + $this->assertStringContainsString( 'ELSE NULL END AS elt_value', $sql ); + $make_set_mask_sql = $this->get_expected_mysql_integer_cast_sql( '9' ); + $this->assertStringContainsString( + sprintf( 'CASE WHEN %1$s IS NULL THEN NULL ELSE CONCAT_WS(\',\', CASE WHEN (%1$s & 1) <> 0 THEN CAST(\'read\' AS text) ELSE NULL END', $make_set_mask_sql ), + $sql + ); + $this->assertStringContainsString( + sprintf( 'CASE WHEN (%1$s & 8) <> 0 THEN CAST(\'execute\' AS text) ELSE NULL END) END AS make_set_value', $make_set_mask_sql ), + $sql + ); + $this->assertStringContainsString( 'AS make_set_empty_value', $sql ); + $this->assertStringContainsString( 'AS make_set_null_value', $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST('hello' AS text)) AS char_length_value", $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST('hello' AS text)) AS character_length_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(3 AS integer)) END AS substring_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(3 AS integer)) END AS substring_from_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(4 AS integer) > 0 THEN CAST(4 AS integer) WHEN CAST(4 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(4 AS integer) + 1 ELSE 0 END) END AS substr_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(2 AS integer)) END AS mid_value", $sql ); + $this->assertStringContainsString( "REPLACE(CAST('banana' AS text), CAST('na' AS text), CAST('NA' AS text)) AS replace_value", $sql ); + $this->assertStringContainsString( 'CASE WHEN 3 IS NULL OR 9 IS NULL OR 4 IS NULL THEN NULL ELSE LEAST(3, 9, 4) END AS least_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN 3 IS NULL OR 9 IS NULL OR 4 IS NULL THEN NULL ELSE GREATEST(3, 9, 4) END AS greatest_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(8 AS double precision) IS NULL OR CAST(8 AS double precision) <= 0 THEN NULL ELSE LN(CAST(8 AS double precision)) END AS natural_log_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(2 AS double precision) IS NULL OR CAST(8 AS double precision) IS NULL OR CAST(2 AS double precision) <= 1 OR CAST(8 AS double precision) <= 0 THEN NULL ELSE LN(CAST(8 AS double precision)) / LN(CAST(2 AS double precision)) END AS based_log_value', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2024-01-05'", "'2024-01-02'" ) . ' AS day_diff', $sql ); + $this->assertStringContainsString( "WHEN 0 THEN 'Sunday'", $sql ); + $this->assertStringContainsString( 'END AS day_name_value', $sql ); + $this->assertStringContainsString( "WHEN 6 THEN 'June'", $sql ); + $this->assertStringContainsString( 'END AS month_name_value', $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST('hello' AS text), 'UTF8')) END AS byte_length", $sql ); + $this->assertStringContainsString( "STRPOS(CAST('WordPress' AS text), CAST('or' AS text)) AS locate_value", $sql ); + $this->assertStringContainsString( "STRPOS(SUBSTRING(CAST('WordPress' AS text) FROM CAST(4 AS integer)), CAST('r' AS text)) + CAST(4 AS integer) - 1 END AS locate_with_position", $sql ); + $this->assertStringContainsString( "STRPOS(CAST('WordPress' AS text), CAST('Press' AS text)) AS instr_value", $sql ); + $this->assertStringContainsString( "ARRAY_POSITION(STRING_TO_ARRAY(CAST('Word,Press,SQLite' AS text), ','), CAST('Press' AS text))", $sql ); + $this->assertStringContainsString( "STRPOS(CAST('with,comma' AS text), ',') > 0 THEN 0", $sql ); + $this->assertStringContainsString( "CAST(NULL AS text) IS NULL OR CAST('Word,Press' AS text) IS NULL THEN NULL", $sql ); + $this->assertStringContainsString( "UPPER(ENCODE(CONVERT_TO(CAST('Az' AS text), 'UTF8'), 'hex')) AS hex_value", $sql ); + $this->assertStringContainsString( "CONVERT_FROM(DECODE(CAST('417a' AS text), 'hex'), 'UTF8') AS unhex_value", $sql ); + $this->assertStringContainsString( "RIGHT(CAST('WordPress' AS text), CAST(5 AS integer)) AS right_part", $sql ); + $this->assertStringContainsString( "ELSE LPAD(CAST('42' AS text), CAST(5 AS integer), CAST('0' AS text)) END AS lpad_value", $sql ); + $this->assertStringContainsString( "ELSE RPAD(CAST('42' AS text), CAST(5 AS integer), CAST('0' AS text)) END AS rpad_value", $sql ); + $this->assertStringContainsString( "ELSE REPEAT(CAST('ab' AS text), GREATEST(CAST(3 AS integer), 0)) END AS repeat_value", $sql ); + $this->assertStringContainsString( "REVERSE(CAST('desserts' AS text)) AS reverse_value", $sql ); + $this->assertStringContainsString( "CASE WHEN 3 IS NULL THEN NULL ELSE REPEAT(' ', GREATEST(CAST(3 AS integer), 0)) END AS space_value", $sql ); + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST('wp' AS text), 'UTF8'), 'base64') AS base64_value", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('d3A=' AS text) IS NULL OR CAST('d3A=' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('d3A=' AS text), 'base64'), 'UTF8') END AS base64_decoded", $sql ); + $this->assertStringContainsString( "SPLIT_PART(CAST('127.0.0.1' AS text), '.', 1)", $sql ); + $this->assertStringContainsString( '((CAST(2130706433 AS bigint) >> 24) & 255)::text', $sql ); + $this->assertStringContainsString( "THEN TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') ELSE", $sql ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( "'YYYY'", $sql ); + $this->assertStringContainsString( 'CAST(FLOOR(EXTRACT(EPOCH FROM CAST(CASE WHEN CAST(\'1970-01-02 00:00:00\' AS text)', $sql ); + $this->assertStringContainsString( 'LOWER(REGEXP_REPLACE(MD5(CAST(CLOCK_TIMESTAMP() AS text) || CAST(RANDOM() AS text) || CAST(PG_BACKEND_PID() AS text))', $sql ); + $this->assertStringContainsString( 'AS uuid_value', $sql ); + $this->assertStringNotContainsString( 'UNIX_TIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + $this->assertStringNotContainsString( 'DAYNAME', $sql ); + $this->assertStringNotContainsString( 'MONTHNAME', $sql ); + $this->assertStringNotContainsString( 'IS_UUID', $sql ); + $this->assertStringNotContainsString( 'INET_ATON', $sql ); + $this->assertStringNotContainsString( 'INET_NTOA', $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + $this->assertStringNotContainsString( 'FIND_IN_SET', $sql ); + $this->assertStringNotContainsString( 'MAKE_SET', $sql ); + $this->assertStringNotContainsString( 'SPACE(', $sql ); + $this->assertStringNotContainsString( "TRIM(' both ')", $sql ); + $this->assertStringNotContainsString( "TRIM(LEADING FROM ' leading')", $sql ); + $this->assertStringNotContainsString( "TRIM('xy' FROM 'xyxytrimxy')", $sql ); + $this->assertStringNotContainsString( 'TRIM(LEADING fallback_value FROM primary_value)', $sql ); + } + + /** + * Tests temporal, schema, and lock compatibility runtime functions are translated. + */ + public function test_sqlite_udf_runtime_compatibility_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CURDATE() AS current_date_value, + CURRENT_DATE AS current_date_keyword_value, + CURRENT_TIME AS current_time_keyword_value, + UTC_DATE() AS utc_date_value, + UTC_TIME() AS utc_time_value, + NOW() AS now_value, + CURRENT_TIMESTAMP AS current_timestamp_keyword_value, + LOCALTIME() AS localtime_value, + LOCALTIME AS localtime_keyword_value, + LOCALTIMESTAMP() AS localtimestamp_value, + LOCALTIMESTAMP AS localtimestamp_keyword_value, + UTC_TIMESTAMP() AS utc_timestamp_value, + DATABASE() AS database_value, + SCHEMA() AS schema_value, + GET_LOCK('plugin_lock', 1) AS get_lock_value, + RELEASE_LOCK('plugin_lock') AS release_lock_value" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS current_date_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS current_date_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS') AS current_time_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS utc_date_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS') AS utc_time_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS now_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS current_timestamp_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtime_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtime_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtimestamp_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtimestamp_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS utc_timestamp_value", $sql ); + $this->assertStringContainsString( "'wptests' AS database_value", $sql ); + $this->assertStringContainsString( "'wptests' AS schema_value", $sql ); + $this->assertStringContainsString( '1 AS get_lock_value', $sql ); + $this->assertStringContainsString( '1 AS release_lock_value', $sql ); + $this->assertStringNotContainsString( 'CURDATE', $sql ); + $this->assertStringNotContainsString( 'UTC_DATE', $sql ); + $this->assertStringNotContainsString( 'UTC_TIME', $sql ); + $this->assertStringNotContainsString( 'NOW', $sql ); + $this->assertStringNotContainsString( 'LOCALTIME', $sql ); + $this->assertStringNotContainsString( 'LOCALTIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'UTC_TIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'DATABASE', $sql ); + $this->assertStringNotContainsString( 'SCHEMA', $sql ); + $this->assertStringNotContainsString( 'GET_LOCK', $sql ); + $this->assertStringNotContainsString( 'RELEASE_LOCK', $sql ); + } + + /** + * Tests every external SQLite UDF registry entry has a PostgreSQL translation path. + */ + public function test_external_sqlite_udf_registry_functions_have_postgresql_translations(): void { + $driver = $this->create_driver(); + $queries = array( + 'curdate' => 'SELECT CURDATE() AS value', + 'datediff' => "SELECT DATEDIFF('2024-01-05', '2024-01-02') AS value", + 'day' => "SELECT DAY('2024-06-16 13:04:05') AS value", + 'dayofmonth' => "SELECT DAYOFMONTH('2024-06-16 13:04:05') AS value", + 'dayofweek' => "SELECT DAYOFWEEK('2024-06-16 13:04:05') AS value", + 'field' => "SELECT FIELD('b', 'a', 'b', 'c') AS value", + 'from_base64' => "SELECT FROM_BASE64('d3A=') AS value", + 'from_unixtime' => 'SELECT FROM_UNIXTIME(0) AS value', + 'get_lock' => "SELECT GET_LOCK('plugin_lock', 1) AS value", + 'greatest' => 'SELECT GREATEST(1, 2, 3) AS value', + 'hour' => "SELECT HOUR('2024-06-16 13:04:05') AS value", + 'if' => "SELECT IF(1, 'yes', 'no') AS value", + 'inet_aton' => "SELECT INET_ATON('127.0.0.1') AS value", + 'inet_ntoa' => 'SELECT INET_NTOA(2130706433) AS value', + 'isnull' => 'SELECT ISNULL(NULL) AS value', + 'lcase' => "SELECT LCASE('ABC') AS value", + 'least' => 'SELECT LEAST(1, 2, 3) AS value', + 'localtime' => 'SELECT LOCALTIME() AS value', + 'localtimestamp' => 'SELECT LOCALTIMESTAMP() AS value', + 'locate' => "SELECT LOCATE('or', 'WordPress') AS value", + 'log' => 'SELECT LOG(8) AS value', + 'md5' => "SELECT MD5('abc') AS value", + 'minute' => "SELECT MINUTE('2024-06-16 13:04:05') AS value", + 'month' => "SELECT MONTH('2024-06-16 13:04:05') AS value", + 'monthnum' => "SELECT MONTHNUM('2024-06-16 13:04:05') AS value", + 'now' => 'SELECT NOW() AS value', + 'rand' => 'SELECT RAND(7) AS value', + 'regexp' => "SELECT REGEXP('^wp', 'WordPress') AS value", + 'release_lock' => "SELECT RELEASE_LOCK('plugin_lock') AS value", + 'second' => "SELECT SECOND('2024-06-16 13:04:05') AS value", + 'to_base64' => "SELECT TO_BASE64('wp') AS value", + 'ucase' => "SELECT UCASE('abc') AS value", + 'unhex' => "SELECT UNHEX('417a') AS value", + 'unix_timestamp' => "SELECT UNIX_TIMESTAMP('1970-01-02 00:00:00') AS value", + 'utc_date' => 'SELECT UTC_DATE() AS value', + 'utc_time' => 'SELECT UTC_TIME() AS value', + 'utc_timestamp' => 'SELECT UTC_TIMESTAMP() AS value', + 'version' => 'SELECT VERSION() AS value', + 'week' => "SELECT WEEK('2024-06-16', 1) AS value", + 'weekday' => "SELECT WEEKDAY('2024-06-16') AS value", + 'year' => "SELECT YEAR('2024-06-16 13:04:05') AS value", + ); + + $registered_functions = $this->get_external_sqlite_udf_registry_functions(); + $translated_functions = array_keys( $queries ); + sort( $registered_functions ); + sort( $translated_functions ); + + $this->assertSame( $registered_functions, $translated_functions ); + + foreach ( $queries as $function_name => $query ) { + $this->assertNotNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $function_name + ); + } + } + + /** + * Tests fractional temporal runtime functions are translated with bounded MySQL precision. + */ + public function test_fractional_temporal_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT CURRENT_TIMESTAMP(6) AS current_timestamp_fsp, + NOW(3) AS now_fsp, + CURRENT_TIME(2) AS current_time_fsp, + UTC_TIME(1) AS utc_time_fsp, + LOCALTIMESTAMP(4) AS localtimestamp_fsp' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(6) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 26) AS current_timestamp_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 23) AS now_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(2) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), 11) AS current_time_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(1) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), 10) AS utc_time_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(4) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 24) AS localtimestamp_fsp", $sql ); + $this->assertStringNotContainsString( 'CURRENT_TIMESTAMP(6) AS current_timestamp_fsp', $sql ); + $this->assertStringNotContainsString( 'NOW(3)', $sql ); + $this->assertStringNotContainsString( 'CURRENT_TIME(2)', $sql ); + } + + /** + * Tests DATE() and DATEDIFF() guard zero-date values before PostgreSQL casts. + */ + public function test_mysql_date_and_datediff_runtime_functions_guard_zero_date_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE('0000-00-00 13:05:00') AS zero_date, + DATE('2020-00-15 13:05:00') AS partial_date, + DATEDIFF('2024-01-05', '0000-00-00') AS diff_zero, + DATEDIFF('2020-00-15', '2020-01-15') AS diff_partial"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( $this->get_expected_mysql_date_sql( "'0000-00-00 13:05:00'" ) . ' AS zero_date', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_date_sql( "'2020-00-15 13:05:00'" ) . ' AS partial_date', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2024-01-05'", "'0000-00-00'" ) . ' AS diff_zero', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2020-00-15'", "'2020-01-15'" ) . ' AS diff_partial', $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('2020-00-15 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringNotContainsString( "TO_CHAR(CAST('0000-00-00 13:05:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00' AS date)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-00-15' AS date)", $sql ); + } + + /** + * Tests SQLite UDF-style REGEXP(pattern, value) runtime calls are translated. + */ + public function test_regexp_runtime_function_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + REGEXP('^rss_.+$', 'RSS_123') AS case_insensitive_match, + REGEXP('^rss_.+$', 'feed_123') AS no_match, + REGEXP(NULL, 'RSS_123') AS null_pattern, + REGEXP('^rss', NULL) AS null_value" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss_.+$' AS text) IS NULL OR CAST('RSS_123' AS text) IS NULL THEN NULL WHEN CAST('RSS_123' AS text) ~* CAST('^rss_.+$' AS text) THEN 1 ELSE 0 END AS case_insensitive_match", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss_.+$' AS text) IS NULL OR CAST('feed_123' AS text) IS NULL THEN NULL WHEN CAST('feed_123' AS text) ~* CAST('^rss_.+$' AS text) THEN 1 ELSE 0 END AS no_match", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST(NULL AS text) IS NULL OR CAST('RSS_123' AS text) IS NULL THEN NULL WHEN CAST('RSS_123' AS text) ~* CAST(NULL AS text) THEN 1 ELSE 0 END AS null_pattern", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss' AS text) IS NULL OR CAST(NULL AS text) IS NULL THEN NULL WHEN CAST(NULL AS text) ~* CAST('^rss' AS text) THEN 1 ELSE 0 END AS null_value", + $sql + ); + $this->assertStringNotContainsString( 'REGEXP(', $sql ); + } + + /** + * Tests nested MySQL base64 runtime functions from the SQLite parity suite are translated. + */ + public function test_nested_base64_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT + TO_BASE64(FROM_BASE64('dGVzdA==')) AS encoded_round_trip, + FROM_BASE64(TO_BASE64('binary')) AS decoded_round_trip, + COALESCE(FROM_BASE64(''), 'fallback') AS empty_decoded"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST(CASE WHEN CAST('dGVzdA==' AS text) IS NULL OR CAST('dGVzdA==' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('dGVzdA==' AS text), 'base64'), 'UTF8') END AS text), 'UTF8'), 'base64') AS encoded_round_trip", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text) IS NULL OR CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text), 'base64'), 'UTF8') END AS decoded_round_trip", $sql ); + $this->assertStringContainsString( "COALESCE(CASE WHEN CAST('' AS text) IS NULL OR CAST('' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('' AS text), 'base64'), 'UTF8') END, 'fallback') AS empty_decoded", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + } + + /** + * Tests NULL-only MySQL base64 runtime calls still trigger PostgreSQL translation. + */ + public function test_null_base64_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT FROM_BASE64(NULL) AS decoded_null, TO_BASE64(NULL) AS encoded_null' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(NULL AS text) IS NULL OR CAST(NULL AS text) !~', $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST(NULL AS text), 'base64'), 'UTF8') END AS decoded_null", $sql ); + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST(NULL AS text), 'UTF8'), 'base64') AS encoded_null", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + } + + /** + * Tests malformed MySQL FROM_BASE64() input returns NULL instead of surfacing a PostgreSQL DECODE error. + */ + public function test_invalid_from_base64_runtime_function_is_guarded_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT FROM_BASE64('not base64!') AS decoded_invalid, LENGTH(FROM_BASE64('abc')) AS invalid_length" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('not base64!' AS text) IS NULL OR CAST('not base64!' AS text) !~", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CONVERT_FROM(DECODE(CAST('not base64!' AS text), 'base64'), 'UTF8') END AS decoded_invalid", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('abc' AS text) IS NULL OR CAST('abc' AS text) !~", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE OCTET_LENGTH(DECODE(CAST('abc' AS text), 'base64')) END AS invalid_length", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + } + + /** + * Tests JSON_VALID() runtime calls fold constants and use native dynamic fallback. + */ + public function test_json_valid_runtime_function_folds_constants_and_uses_native_fallback_for_dynamic_values(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT JSON_VALID(\'{"ok":true}\') AS object_valid, + JSON_VALID(CONCAT(\'{"ok":\', \'true}\')) AS concat_object_valid, + JSON_VALID(CONCAT_WS(\'\', \'[\', \'1\', \']\')) AS concat_array_valid, + JSON_VALID(REPLACE(\'{"bad":true}\', \'bad\', \'ok\')) AS replace_object_valid, + JSON_VALID(SUBSTRING(\'xx[1]\', 3)) AS substring_array_valid, + JSON_VALID(IFNULL(NULL, \'{"fallback":true}\')) AS ifnull_object_valid, + JSON_VALID(CONCAT(\'{\', NULL, \'}\')) AS concat_null_valid, + JSON_VALID(NULLIF(\'[1]\', \'[1]\')) AS nullif_null_valid, + JSON_VALID(\'not json\') AS invalid_json, + JSON_VALID(NULL) AS null_valid, + JSON_VALID(payload) AS payload_valid + FROM runtime_names' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( '1 AS object_valid', $sql ); + $this->assertStringContainsString( '1 AS concat_object_valid', $sql ); + $this->assertStringContainsString( '1 AS concat_array_valid', $sql ); + $this->assertStringContainsString( '1 AS replace_object_valid', $sql ); + $this->assertStringContainsString( '1 AS substring_array_valid', $sql ); + $this->assertStringContainsString( '1 AS ifnull_object_valid', $sql ); + $this->assertStringContainsString( 'NULL AS concat_null_valid', $sql ); + $this->assertStringContainsString( 'NULL AS nullif_null_valid', $sql ); + $this->assertStringContainsString( '0 AS invalid_json', $sql ); + $this->assertStringContainsString( 'NULL AS null_valid', $sql ); + $this->assertStringContainsString( 'CASE WHEN payload IS NULL THEN NULL ELSE json_valid(CAST(payload AS text)) END AS payload_valid', $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_json_valid', $sql ); + $this->assertStringNotContainsString( 'JSON_VALID', $sql ); + $this->assertStringNotContainsString( 'pg_input_is_valid', $sql ); + } + + /** + * Tests JSON_VALID() runtime execution preserves MySQL NULL/0/1 semantics. + */ + public function test_json_valid_runtime_function_executes_with_mysql_semantics(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_json_valid_runtime (id INTEGER PRIMARY KEY, payload TEXT)' ); + $driver->query( "INSERT INTO wptests_json_valid_runtime (id, payload) VALUES (1, '{\"ok\":true}'), (2, 'not json'), (3, NULL), (4, 'null')" ); + + $rows = $driver->query( 'SELECT id, JSON_VALID(payload) AS payload_valid FROM wptests_json_valid_runtime ORDER BY id' ); + + $this->assertSame( + array( + array( '1', '1' ), + array( '2', '0' ), + array( '3', null ), + array( '4', '1' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->payload_valid ); + }, + $rows + ) + ); + + $literal_result = $driver->query( + 'SELECT JSON_VALID(\'{"ok":true}\') AS object_valid, + JSON_VALID(\'[1,2]\') AS array_valid, + JSON_VALID(\'not json\') AS invalid_json, + JSON_VALID(NULL) AS null_json, + JSON_VALID(\'null\') AS null_literal_valid, + JSON_VALID(123) AS number_valid' + ); + + $this->assertCount( 1, $literal_result ); + $this->assertSame( '1', $literal_result[0]->object_valid ); + $this->assertSame( '1', $literal_result[0]->array_valid ); + $this->assertSame( '0', $literal_result[0]->invalid_json ); + $this->assertNull( $literal_result[0]->null_json ); + $this->assertSame( '1', $literal_result[0]->null_literal_valid ); + $this->assertSame( '1', $literal_result[0]->number_valid ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '1 AS object_valid', $sql ); + $this->assertStringContainsString( '1 AS array_valid', $sql ); + $this->assertStringContainsString( '0 AS invalid_json', $sql ); + $this->assertStringContainsString( 'NULL AS null_json', $sql ); + $this->assertStringContainsString( '1 AS null_literal_valid', $sql ); + $this->assertStringContainsString( '1 AS number_valid', $sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_json_valid', $sql ); + $this->assertStringNotContainsString( 'JSON_VALID', $sql ); + $this->assertStringNotContainsString( 'pg_input_is_valid', $sql ); + } + + /** + * Tests common MySQL runtime functions trigger rewrite without literal arguments. + */ + public function test_common_mysql_runtime_functions_with_column_arguments_trigger_postgresql_rewrite(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT IFNULL(primary_value, fallback_value) AS selected_value, NULLIF(primary_value, fallback_value) AS nullif_value, CONCAT(prefix, suffix) AS joined_value, CONCAT_WS('-', prefix, NULL, suffix) AS joined_ws_value, CHAR_LENGTH(display_name) AS name_length, LENGTH(display_name) AS byte_length FROM runtime_names" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'COALESCE(primary_value, fallback_value) AS selected_value', $sql ); + $this->assertStringContainsString( 'NULLIF(primary_value, fallback_value) AS nullif_value', $sql ); + $this->assertStringContainsString( '(CAST(prefix AS text) || CAST(suffix AS text)) AS joined_value', $sql ); + $this->assertStringContainsString( "CASE WHEN '-' IS NULL THEN NULL ELSE", $sql ); + $this->assertStringContainsString( "COALESCE(CAST(prefix AS text), '')", $sql ); + $this->assertStringContainsString( "COALESCE(CAST(suffix AS text), '')", $sql ); + $this->assertStringContainsString( 'END AS joined_ws_value', $sql ); + $this->assertStringContainsString( 'CHAR_LENGTH(CAST(display_name AS text)) AS name_length', $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(display_name AS text), 'UTF8')) END AS byte_length", $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + $this->assertStringNotContainsString( 'CONCAT_WS', $sql ); + } + + /** + * Tests MySQL CONCAT_WS() skips NULL values while preserving empty strings. + */ + public function test_concat_ws_runtime_function_executes_mysql_null_semantics(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + "SELECT + CONCAT_WS('-', 'wp', NULL, 'db') AS skipped_null, + CONCAT_WS(',', '', NULL, 'tail') AS empty_string_kept, + CONCAT_WS(NULL, 'a', 'b') AS null_separator, + CONCAT_WS('|', NULL, NULL) AS all_values_null" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wp-db', $rows[0]->skipped_null ); + $this->assertSame( ',tail', $rows[0]->empty_string_kept ); + $this->assertNull( $rows[0]->null_separator ); + $this->assertSame( '', $rows[0]->all_values_null ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringNotContainsString( 'CONCAT_WS', $sql ); + } + + /** + * Tests MySQL LENGTH() counts bytes for UTF-8 text. + */ + public function test_length_runtime_function_counts_utf8_bytes_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + LENGTH(UNHEX('c3a9')) AS utf8_byte_length, + LENGTH(UNHEX('ff')) AS binary_byte_length, + LENGTH(x'c3a9') AS raw_hex_byte_length, + LENGTH(0xC3A9) AS prefixed_hex_byte_length" + ); + + $this->assertSame( + "SELECT OCTET_LENGTH(DECODE(CAST('c3a9' AS text), 'hex')) AS utf8_byte_length, OCTET_LENGTH(DECODE(CAST('ff' AS text), 'hex')) AS binary_byte_length, 2 AS raw_hex_byte_length, 2 AS prefixed_hex_byte_length", + $sql + ); + } + + /** + * Tests CHAR_LENGTH() counts bytes for binary-producing runtime functions. + */ + public function test_char_length_binary_runtime_functions_count_bytes_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + CHAR_LENGTH(UNHEX('c3a9')) AS unhex_char_bytes, + CHARACTER_LENGTH(UNHEX('ff')) AS unhex_raw_bytes, + CHAR_LENGTH(FROM_BASE64('w6k=')) AS base64_char_bytes, + CHAR_LENGTH(x'c3a9') AS raw_hex_char_bytes, + CHARACTER_LENGTH(0xC3A9) AS prefixed_hex_char_bytes" + ); + + $this->assertSame( + "SELECT OCTET_LENGTH(DECODE(CAST('c3a9' AS text), 'hex')) AS unhex_char_bytes, OCTET_LENGTH(DECODE(CAST('ff' AS text), 'hex')) AS unhex_raw_bytes, CASE WHEN CAST('w6k=' AS text) IS NULL OR CAST('w6k=' AS text) !~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' THEN NULL ELSE OCTET_LENGTH(DECODE(CAST('w6k=' AS text), 'base64')) END AS base64_char_bytes, 2 AS raw_hex_char_bytes, 2 AS prefixed_hex_char_bytes", + $sql + ); + } + + /** + * Tests formatted FROM_UNIXTIME() shares DATE_FORMAT coverage and NULL semantics. + */ + public function test_from_unixtime_formatted_runtime_function_is_translated_with_null_guard(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + FROM_UNIXTIME(0.123456, '%Y-%m-%d %H:%i:%s.%f') AS formatted_epoch, + FROM_UNIXTIME(0, '%H.%i') AS formatted_hour_minute, + FROM_UNIXTIME(0, '%H.%i%s') AS formatted_hour_minute_second, + FROM_UNIXTIME(0, '0.%i%s') AS formatted_minute_second_fraction, + FROM_UNIXTIME(1609632000, '%U %u %V %v %X %x') AS formatted_week_modes, + FROM_UNIXTIME(0, CONCAT('%Y', '-%m')) AS constant_expression_format, + FROM_UNIXTIME(0, CONCAT_WS('-', '%Y', '%m', '%d')) AS constant_ws_expression_format, + FROM_UNIXTIME(0, REPLACE('%Y/%m/%d', '/', '-')) AS constant_replace_expression_format, + FROM_UNIXTIME(0, SUBSTRING('xx%Y-%m-%d', 3, 8)) AS constant_substring_expression_format, + FROM_UNIXTIME(0, RIGHT('ignored%Y-%m', 5)) AS constant_right_expression_format, + FROM_UNIXTIME(0, LPAD('%m', 5, '%Y-')) AS constant_lpad_expression_format, + FROM_UNIXTIME(0, RPAD('%Y', 5, '-%m')) AS constant_rpad_expression_format, + FROM_UNIXTIME(0, REPEAT('%Y', 1)) AS constant_repeat_expression_format, + FROM_UNIXTIME(0, TRIM(' %Y-%m ')) AS constant_trim_expression_format, + FROM_UNIXTIME(0, LTRIM(' %Y')) AS constant_ltrim_expression_format, + FROM_UNIXTIME(0, RTRIM('%m ')) AS constant_rtrim_expression_format, + FROM_UNIXTIME(0, CONCAT('%Y', SPACE(1), '%m')) AS constant_space_expression_format, + FROM_UNIXTIME(0, REVERSE('Y%')) AS constant_reverse_expression_format, + FROM_UNIXTIME(0, ELT(2, '%m', '%Y')) AS constant_elt_expression_format, + FROM_UNIXTIME(0, IFNULL(NULL, '%Y-%m')) AS constant_ifnull_expression_format, + FROM_UNIXTIME(0, CONCAT('%Y', NULL)) AS null_expression_format, + FROM_UNIXTIME(0, NULLIF('%Y', '%Y')) AS null_nullif_expression_format, + FROM_UNIXTIME(NULL, 'literal') AS null_literal, + FROM_UNIXTIME(NULL, '') AS null_empty_from_unixtime, + DATE_FORMAT(NULL, '%%') AS null_percent, + DATE_FORMAT(NULL, '') AS null_empty_format" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0.123456 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( "'HH24') || '.' || TO_CHAR", $sql ); + $this->assertStringContainsString( "'MI') END AS formatted_hour_minute", $sql ); + $this->assertStringContainsString( "'MI') || TO_CHAR", $sql ); + $this->assertStringContainsString( "'SS') END AS formatted_hour_minute_second", $sql ); + $this->assertStringContainsString( "'0.' || TO_CHAR", $sql ); + $this->assertStringContainsString( "'SS') END AS formatted_minute_second_fraction", $sql ); + $week_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( "TO_TIMESTAMP(CAST(1609632000 AS double precision)) AT TIME ZONE 'UTC'" ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_zero_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_week_mode_one_timestamp_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_two_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( 'TO_CHAR(' . $week_timestamp_sql . ", 'IW')", $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_sunday_week_mode_two_year_sql( $week_timestamp_sql ), $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $week_timestamp_sql . ", 'IYYY')", $sql ); + $this->assertStringNotContainsString( "CAST(TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'HH24.MI') AS double precision)", $sql ); + $this->assertStringNotContainsString( "CAST(TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'HH24.MISS') AS double precision)", $sql ); + $this->assertStringNotContainsString( "CAST('0.' || TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'MISS') AS double precision)", $sql ); + $this->assertStringContainsString( "'YYYY'", $sql ); + $this->assertStringContainsString( "'MM'", $sql ); + $this->assertStringContainsString( "'DD'", $sql ); + $this->assertStringContainsString( "'HH24'", $sql ); + $this->assertStringContainsString( "'MI'", $sql ); + $this->assertStringContainsString( "'SS'", $sql ); + $this->assertStringContainsString( "'US'", $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( "TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC'" ) . ', \'YYYY\') || \'-\' || TO_CHAR', $sql ); + $this->assertStringContainsString( "'DD') END AS constant_ws_expression_format", $sql ); + $this->assertStringContainsString( 'AS constant_ws_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_replace_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_substring_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_right_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_lpad_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_rpad_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_repeat_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_trim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_ltrim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_rtrim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_space_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_reverse_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_elt_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_ifnull_expression_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_expression_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_nullif_expression_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_literal', $sql ); + $this->assertStringContainsString( 'NULL AS null_empty_from_unixtime', $sql ); + $this->assertStringContainsString( 'NULL AS null_percent', $sql ); + $this->assertStringContainsString( 'NULL AS null_empty_format', $sql ); + $this->assertStringNotContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringNotContainsString( "TRIM(' %Y-%m ')", $sql ); + $this->assertStringNotContainsString( "LTRIM(' %Y')", $sql ); + $this->assertStringNotContainsString( "RTRIM('%m ')", $sql ); + $this->assertStringNotContainsString( 'SPACE(1)', $sql ); + $this->assertStringNotContainsString( "REVERSE('Y%')", $sql ); + $this->assertStringNotContainsString( "ELT(2, '%m', '%Y')", $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests FROM_UNIXTIME() honors session time_zone and preserves fractional seconds. + */ + public function test_from_unixtime_runtime_function_honors_time_zone_and_fractional_seconds(): void { + $driver = $this->create_driver(); + $driver->query( "SET SESSION time_zone = '+02:30'" ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + FROM_UNIXTIME(0) AS whole_epoch, + FROM_UNIXTIME(0.123456) AS fractional_epoch, + FROM_UNIXTIME(0, '%q %%') AS unknown_specifier" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC' + INTERVAL '150 minutes')", $sql ); + $this->assertStringContainsString( "TO_CHAR((TO_TIMESTAMP(CAST(0.123456 AS double precision)) AT TIME ZONE 'UTC' + INTERVAL '150 minutes'), 'YYYY-MM-DD HH24:MI:SS.US')", $sql ); + $this->assertStringContainsString( "'q ' || '%'", $sql ); + $this->assertStringNotContainsString( "'%q '", $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests DATE_FORMAT() supports runtime format expressions like SQLite's UDF. + */ + public function test_mysql_date_format_runtime_format_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + DATE_FORMAT(post_date, format_mask) AS dynamic_column_format, + DATE_FORMAT(post_date, CONCAT('%Y', '-%m')) AS constant_expression_format, + DATE_FORMAT(post_date, CONCAT_WS('-', '%Y', '%m', '%d')) AS constant_ws_expression_format, + DATE_FORMAT(post_date, REPLACE('%Y/%m/%d', '/', '-')) AS constant_replace_expression_format, + DATE_FORMAT(post_date, LEFT('%Y-%m-%d ignored', 8)) AS constant_left_expression_format, + DATE_FORMAT(post_date, RIGHT('ignored %Y-%m', 5)) AS constant_right_expression_format, + DATE_FORMAT(post_date, LPAD('%m', 5, '%Y-')) AS constant_lpad_expression_format, + DATE_FORMAT(post_date, RPAD('%Y', 5, '-%m')) AS constant_rpad_expression_format, + DATE_FORMAT(post_date, REPEAT('%Y', 1)) AS constant_repeat_expression_format, + DATE_FORMAT(post_date, TRIM(' %Y-%m ')) AS constant_trim_expression_format, + DATE_FORMAT(post_date, LTRIM(' %Y')) AS constant_ltrim_expression_format, + DATE_FORMAT(post_date, RTRIM('%m ')) AS constant_rtrim_expression_format, + DATE_FORMAT(post_date, CONCAT('%Y', SPACE(1), '%m')) AS constant_space_expression_format, + DATE_FORMAT(post_date, REVERSE('Y%')) AS constant_reverse_expression_format, + DATE_FORMAT(post_date, ELT(2, '%m', '%Y')) AS constant_elt_expression_format, + DATE_FORMAT(post_date, COALESCE(NULL, '%Y-%m')) AS constant_coalesce_expression_format, + DATE_FORMAT(post_date, IF(format_mask = '%Y', '%Y', '%m')) AS finite_if_expression_format, + DATE_FORMAT(post_date, IF(format_mask = '%Y', '%Y', NULL)) AS finite_if_null_expression_format, + DATE_FORMAT(post_date, CASE WHEN format_mask = '%Y' THEN '%Y' WHEN format_mask = '%m' THEN '%m' ELSE NULL END) AS finite_case_expression_format, + DATE_FORMAT(post_date, CASE format_mask WHEN '%Y' THEN '%Y' ELSE '%m' END) AS finite_simple_case_expression_format, + DATE_FORMAT(post_date, CONCAT('%Y-', NULL)) AS null_expression_format, + DATE_FORMAT(post_date, NULLIF('%H', '%H')) AS null_nullif_expression_format, + DATE_FORMAT(NULL, format_mask) AS null_date_format, + DATE_FORMAT(post_date, NULL) AS null_mask_format + FROM wptests_dynamic_date_formats" + ); + + $this->assertNotNull( $sql ); + $this->assertSame( 2, substr_count( $sql, 'WITH RECURSIVE "__wp_pg_mysql_date_format"' ) ); + $this->assertStringContainsString( 'CAST(format_mask AS text)', $sql ); + $this->assertStringNotContainsString( "(CAST('%Y' AS text) || CAST('-%m' AS text))", $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ', \'YYYY\') || \'-\' || TO_CHAR', $sql ); + $this->assertStringContainsString( "'YYYY-MM-DD'", $sql ); + $this->assertStringContainsString( 'AS constant_ws_expression_format', $sql ); + $this->assertStringNotContainsString( "CONCAT_WS('-', '%Y', '%m', '%d')", $sql ); + $this->assertStringContainsString( 'AS constant_replace_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_left_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_right_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_lpad_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_rpad_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_repeat_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_trim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_ltrim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_rtrim_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_space_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_reverse_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_elt_expression_format', $sql ); + $this->assertStringContainsString( 'AS constant_coalesce_expression_format', $sql ); + $this->assertStringContainsString( 'AS finite_if_expression_format', $sql ); + $this->assertStringContainsString( 'AS finite_if_null_expression_format', $sql ); + $this->assertStringContainsString( 'AS finite_case_expression_format', $sql ); + $this->assertStringContainsString( 'AS finite_simple_case_expression_format', $sql ); + $this->assertStringContainsString( "CASE WHEN (format_mask = '%Y') THEN", $sql ); + $this->assertStringContainsString( 'ELSE NULL END AS finite_if_null_expression_format', $sql ); + $this->assertStringContainsString( "WHEN (format_mask = '%m') THEN", $sql ); + $this->assertStringContainsString( 'ELSE NULL END AS finite_case_expression_format', $sql ); + $this->assertStringNotContainsString( "REPLACE('%Y/%m/%d', '/', '-')", $sql ); + $this->assertStringNotContainsString( "LEFT('%Y-%m-%d ignored', 8)", $sql ); + $this->assertStringNotContainsString( "RIGHT('ignored %Y-%m', 5)", $sql ); + $this->assertStringNotContainsString( "LPAD('%m', 5, '%Y-')", $sql ); + $this->assertStringNotContainsString( "RPAD('%Y', 5, '-%m')", $sql ); + $this->assertStringNotContainsString( "REPEAT('%Y', 1)", $sql ); + $this->assertStringNotContainsString( "TRIM(' %Y-%m ')", $sql ); + $this->assertStringNotContainsString( "LTRIM(' %Y')", $sql ); + $this->assertStringNotContainsString( "RTRIM('%m ')", $sql ); + $this->assertStringNotContainsString( 'SPACE(1)', $sql ); + $this->assertStringNotContainsString( "REVERSE('Y%')", $sql ); + $this->assertStringNotContainsString( "ELT(2, '%m', '%Y')", $sql ); + $this->assertStringNotContainsString( "COALESCE(NULL, '%Y-%m')", $sql ); + $this->assertStringNotContainsString( "IF(format_mask = '%Y'", $sql ); + $this->assertStringNotContainsString( "CASE WHEN format_mask = '%Y' THEN '%Y'", $sql ); + $this->assertStringNotContainsString( "CASE format_mask WHEN '%Y'", $sql ); + $this->assertStringContainsString( 'NULL AS null_expression_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_nullif_expression_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_date_format', $sql ); + $this->assertStringContainsString( 'NULL AS null_mask_format', $sql ); + $this->assertStringContainsString( "WHEN 'Y' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( "WHEN 'D' THEN CAST(CAST(EXTRACT(DAY FROM", $sql ); + $this->assertStringContainsString( "WHEN 'w' THEN CAST(CAST(EXTRACT(DOW FROM", $sql ); + $this->assertStringContainsString( "WHEN 'U' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'u' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'V' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'v' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( "WHEN 'X' THEN CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'x' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( 'ELSE SUBSTRING', $sql ); + $this->assertStringNotContainsString( "ELSE '%' || SUBSTRING", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT DATE_FORMAT(post_date, format_mask) AS dynamic_column_format FROM wptests_dynamic_date_formats' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests runtime DATE_FORMAT() masks preserve derivable zero-date parts. + */ + public function test_mysql_date_format_runtime_format_preserves_zero_date_numeric_and_time_parts_for_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00 13:04:05.123' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00 13:04:05.123', format_mask) AS dynamic_zero_format + FROM wptests_dynamic_date_formats" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN (WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringContainsString( 'WHEN \'Y\' THEN SUBSTRING(' . $expression_sql . ' FROM 1 FOR 4)', $sql ); + $this->assertStringContainsString( 'WHEN \'m\' THEN SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2)', $sql ); + $this->assertStringContainsString( 'WHEN \'d\' THEN SUBSTRING(' . $expression_sql . ' FROM 9 FOR 2)', $sql ); + $this->assertStringContainsString( "WHEN 'H' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 'i' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 15 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 's' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 18 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 'f' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING($expression_sql FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", $sql ); + $this->assertStringContainsString( "WHEN 'W' THEN NULL", $sql ); + $this->assertStringContainsString( 'ELSE SUBSTRING(CAST(format_mask AS text) FROM "__wp_pg_mysql_date_format"."position" + 1 FOR 1) END', $sql ); + $this->assertStringNotContainsString( "ELSE '%' || SUBSTRING", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00 13:04:05.123' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests formatted FROM_UNIXTIME() supports runtime format expressions. + */ + public function test_from_unixtime_runtime_format_expression_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT FROM_UNIXTIME(0, format_mask) AS formatted_epoch, + FROM_UNIXTIME(0, IF(format_mask = \'%Y\', \'%Y\', \'%m\')) AS finite_if_epoch, + FROM_UNIXTIME(0, CASE WHEN format_mask = \'%Y\' THEN \'%Y\' ELSE \'%m\' END) AS finite_case_epoch, + FROM_UNIXTIME(0, CASE format_mask WHEN \'%Y\' THEN \'%Y\' ELSE \'%m\' END) AS finite_simple_case_epoch, + FROM_UNIXTIME(NULL, format_mask) AS null_dynamic_epoch + FROM wptests_unix_time_formats' + ); + + $this->assertNotNull( $sql ); + $this->assertSame( 2, substr_count( $sql, 'WITH RECURSIVE "__wp_pg_mysql_date_format"' ) ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( 'CAST(format_mask AS text)', $sql ); + $this->assertStringContainsString( 'AS finite_if_epoch', $sql ); + $this->assertStringContainsString( 'AS finite_case_epoch', $sql ); + $this->assertStringContainsString( 'AS finite_simple_case_epoch', $sql ); + $this->assertStringContainsString( "CASE WHEN (format_mask = '%Y') THEN", $sql ); + $this->assertStringContainsString( 'NULL AS null_dynamic_epoch', $sql ); + $this->assertStringNotContainsString( "IF(format_mask = '%Y'", $sql ); + $this->assertStringNotContainsString( "CASE WHEN format_mask = '%Y' THEN '%Y'", $sql ); + $this->assertStringNotContainsString( "CASE format_mask WHEN '%Y'", $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests unsupported common MySQL runtime function forms are left without compatibility translations. + */ + public function test_unsupported_common_mysql_runtime_function_forms_return_null_translation(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT CONCAT() AS empty_concat', + "SELECT CONCAT_WS('-') AS invalid_concat_ws", + 'SELECT ASCII() AS invalid_ascii', + 'SELECT ASCII(primary_value, fallback_value) AS invalid_ascii FROM runtime_names', + 'SELECT DAYNAME() AS invalid_dayname', + 'SELECT DAYNAME(primary_value, fallback_value) AS invalid_dayname FROM runtime_names', + 'SELECT ELT(1) AS invalid_elt', + 'SELECT FIND_IN_SET(primary_value) AS invalid_find_in_set FROM runtime_names', + 'SELECT FIND_IN_SET(primary_value, fallback_value, third_value) AS invalid_find_in_set FROM runtime_names', + 'SELECT IFNULL(primary_value) AS invalid_ifnull FROM runtime_names', + 'SELECT INSTR(primary_value) AS invalid_instr FROM runtime_names', + 'SELECT INSTR(primary_value, fallback_value, third_value) AS invalid_instr FROM runtime_names', + 'SELECT IS_UUID() AS invalid_is_uuid', + 'SELECT IS_UUID(primary_value, fallback_value) AS invalid_is_uuid FROM runtime_names', + 'SELECT NULLIF(primary_value) AS invalid_nullif FROM runtime_names', + 'SELECT NULLIF(primary_value, fallback_value, third_value) AS invalid_nullif FROM runtime_names', + 'SELECT JSON_VALID() AS invalid_json', + 'SELECT JSON_VALID(payload, fallback_value) AS invalid_json FROM runtime_names', + 'SELECT LOG() AS invalid_log', + 'SELECT LTRIM(primary_value, fallback_value) AS invalid_ltrim FROM runtime_names', + 'SELECT MAKE_SET() AS invalid_make_set', + 'SELECT MAKE_SET(primary_value) AS invalid_make_set FROM runtime_names', + 'SELECT MONTHNAME() AS invalid_monthname', + 'SELECT MONTHNAME(primary_value, fallback_value) AS invalid_monthname FROM runtime_names', + 'SELECT REVERSE() AS invalid_reverse', + 'SELECT REVERSE(primary_value, fallback_value) AS invalid_reverse FROM runtime_names', + 'SELECT RTRIM(primary_value, fallback_value) AS invalid_rtrim FROM runtime_names', + 'SELECT SPACE() AS invalid_space', + 'SELECT SPACE(1, 2) AS invalid_space', + 'SELECT TRIM() AS invalid_trim', + 'SELECT TRIM(primary_value, fallback_value) AS invalid_trim FROM runtime_names', + 'SELECT CURRENT_TIMESTAMP(7) AS invalid_fractional_timestamp', + 'SELECT CURRENT_USER(1) AS invalid_current_user', + 'SELECT FOUND_ROWS(1) AS invalid_found_rows', + 'SELECT ROW_COUNT(123) AS rows_changed', + 'SELECT UUID(1) AS invalid_uuid', + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests unsupported known MySQL runtime functions fail before backend execution. + */ + public function test_unsupported_common_mysql_runtime_function_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT CONCAT() AS empty_concat', + "SELECT CONCAT_WS('-') AS invalid_concat_ws", + 'SELECT DAYNAME() AS invalid_dayname', + 'SELECT DAYNAME(primary_value, fallback_value) AS invalid_dayname FROM runtime_names', + 'SELECT FIND_IN_SET(primary_value) AS invalid_find_in_set FROM runtime_names', + 'SELECT FIND_IN_SET(primary_value, fallback_value, third_value) AS invalid_find_in_set FROM runtime_names', + 'SELECT IFNULL(primary_value) AS invalid_ifnull FROM runtime_names', + 'SELECT IS_UUID() AS invalid_is_uuid', + 'SELECT IS_UUID(primary_value, fallback_value) AS invalid_is_uuid FROM runtime_names', + 'SELECT NULLIF(primary_value) AS invalid_nullif FROM runtime_names', + 'SELECT NULLIF(primary_value, fallback_value, third_value) AS invalid_nullif FROM runtime_names', + 'SELECT JSON_VALID() AS invalid_json', + 'SELECT JSON_VALID(payload, fallback_value) AS invalid_json FROM runtime_names', + 'SELECT LOG() AS invalid_log', + 'SELECT MAKE_SET() AS invalid_make_set', + 'SELECT MAKE_SET(primary_value) AS invalid_make_set FROM runtime_names', + 'SELECT MONTHNAME() AS invalid_monthname', + 'SELECT MONTHNAME(primary_value, fallback_value) AS invalid_monthname FROM runtime_names', + 'SELECT TRIM() AS invalid_trim', + 'SELECT TRIM(primary_value, fallback_value) AS invalid_trim FROM runtime_names', + 'SELECT CURRENT_TIMESTAMP(7) AS invalid_fractional_timestamp', + "SELECT FROM_UNIXTIME(0, '%Y', 'extra') AS invalid_from_unixtime", + "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id", + 'SELECT CURRENT_USER(1) AS invalid_current_user', + 'SELECT USER(1) AS invalid_user', + 'SELECT FOUND_ROWS(1) AS invalid_found_rows', + 'SELECT ROW_COUNT(123) AS rows_changed', + 'SELECT UUID(1) AS invalid_uuid', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL runtime function form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported DATE_FORMAT() and RAND() forms fail before backend execution. + */ + public function test_unsupported_date_format_and_rand_runtime_function_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT DATE_FORMAT() AS invalid_date_format', + "SELECT DATE_FORMAT('2024-01-01') AS invalid_date_format", + "SELECT DATE_FORMAT('2024-01-01', '%Y', 'extra') AS invalid_date_format", + 'SELECT RAND(1, 2) AS invalid_rand', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL runtime function form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests WordPress sticky base queries get MySQL's posts date ID tie-breaker. + */ + public function test_wordpress_posts_post_date_desc_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 5; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 5"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '5', '4', '3', '2', '1' ), + array_map( + static function ( $row ) { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\'))) ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 5 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests leading-comment SELECTs still use the SELECT translator chain. + */ + public function test_leading_comment_select_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 3; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $select = "/* cache gate */ SELECT wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' AND wptests_posts.post_status = 'publish' + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 3"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 3 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests non-descending posts date order does not get the sticky tie-breaker. + */ + public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' + ORDER BY wptests_posts.post_date ASC + LIMIT 0, 5"; + $driver->query( $select ); + + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' ORDER BY wptests_posts.post_date ASC LIMIT 5 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped posts post_date DESC order keeps MySQL's ID tie-breaker. + */ + public function test_wordpress_grouped_posts_post_date_desc_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 3; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' AND wptests_posts.post_status = 'publish' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 3" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' GROUP BY wptests_posts."ID" ORDER BY MAX(wptests_posts.post_date) DESC, wptests_posts."ID" DESC LIMIT 3 OFFSET 0', + 'params' => array(), + ), + array( + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' GROUP BY wptests_posts."ID") AS "__wp_pg_found_rows"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests admin page search ordering uses MySQL-compatible ID tie-breakers. + */ + public function test_wordpress_admin_page_search_menu_order_title_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + `menu_order` int(11) NOT NULL DEFAULT 0, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (12, 5, 0, 'Child 1', '', '', '', 'page', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (9, 4, 0, 'Child 1', '', '', '', 'page', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (10, 4, 0, 'Child 2', '', '', '', 'page', 'publish')" ); + + $rows = $driver->query( + "SELECT wptests_posts.* + FROM wptests_posts + WHERE 1=1 + AND (((wptests_posts.post_title LIKE '%Child%') OR (wptests_posts.post_excerpt LIKE '%Child%') OR (wptests_posts.post_content LIKE '%Child%'))) + AND (wptests_posts.post_password = '') + AND ((wptests_posts.post_type = 'page' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.menu_order ASC, wptests_posts.post_title ASC" + ); + + $this->assertSame( + array( '9', '12', '10' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY wptests_posts.menu_order ASC, LOWER(wptests_posts.post_title) ASC, wptests_posts."ID" ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests available post MIME type lookups use MySQL-compatible first posts.ID ordering. + */ + public function test_wordpress_available_post_mime_types_distinct_orders_by_first_post_id(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (1, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (2, 'attachment', 'application/pdf')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (3, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (4, 'post', 'text/plain')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (5, 'attachment', '')" ); + + $rows = $driver->query( + "SELECT DISTINCT post_mime_type + FROM wptests_posts + WHERE post_type = 'attachment' AND post_mime_type != ''" + ); + + $this->assertSame( + array( 'image/jpeg', 'application/pdf' ), + array_map( + static function ( $row ): string { + return $row->post_mime_type; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_mime_type FROM wptests_posts WHERE post_type = \'attachment\' AND post_mime_type != \'\' GROUP BY post_mime_type ORDER BY MIN("ID") ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress postmeta key lookups with HAVING but no GROUP BY match SQLite parity. + */ + public function test_wordpress_postmeta_distinct_meta_key_having_without_group_by_is_rewritten(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_postmeta (meta_id, meta_key) VALUES + (1, '_hidden'), + (2, 'visible_meta_key_03'), + (3, 'visible_meta_key_01'), + (4, 'visible_meta_key_02'), + (5, 'visible_meta_key_02')" + ); + + $queries = array( + "SELECT DISTINCT meta_key FROM wptests_postmeta WHERE meta_key NOT BETWEEN '_' AND '_z' HAVING meta_key NOT LIKE '\\_%' ORDER BY meta_key LIMIT 3", + "SELECT DISTINCT meta_key FROM wptests_postmeta WHERE meta_key NOT BETWEEN '_' AND '_z' HAVING meta_key NOT LIKE CONCAT('\\_', '%') ORDER BY meta_key LIMIT 3", + ); + + foreach ( $queries as $query ) { + $rows = $driver->query( $query ); + + $this->assertSame( + array( 'visible_meta_key_01', 'visible_meta_key_02', 'visible_meta_key_03' ), + array_map( + static function ( $row ): string { + return $row->meta_key; + }, + $rows + ), + $query + ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT DISTINCT meta_key FROM wptests_postmeta WHERE (meta_key NOT BETWEEN', $sql, $query ); + $this->assertStringContainsString( ') AND (meta_key NOT LIKE', $sql, $query ); + $this->assertStringContainsString( 'ORDER BY meta_key LIMIT 3', $sql, $query ); + $this->assertStringNotContainsString( ' HAVING ', $sql, $query ); + $this->assertStringNotContainsString( 'CONCAT(', $sql, $query ); + } + } + + /** + * Tests 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 uses stateless catalog reads. + */ + public function test_wordpress_column_reference_metadata_uses_stateless_catalog_reads(): 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`) + )' + ); + + $query = "SELECT post_title FROM wptests_posts, wptests_terms WHERE post_title LIKE '%test%'"; + + $driver->query( $query ); + $this->assertStringContainsString( + "LOWER(post_title) LIKE LOWER('%test%')", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $driver->query( $query ); + + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 ); + } + + /** + * Tests qualified column-name metadata uses stateless catalog reads. + */ + public function test_wordpress_column_name_metadata_uses_stateless_catalog_reads(): 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`) + )' + ); + + $query = 'SELECT p.ID FROM wptests_posts AS p WHERE p.ID > 0'; + + $driver->query( $query ); + + $driver->query( $query ); + + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 ); + } + + /** + * 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'] ); + + $last_cache = $this->get_driver_private_property( $driver, 'mysql_select_translation_last_cache' ); + $this->assertIsArray( $last_cache ); + $this->assertSame( $query, $last_cache['query'] ); + $this->assertSame( $entry['sql'], $last_cache['sql'] ); + $this->assertTrue( $last_cache['translated'] ); + + $driver->query( $query ); + + $this->assertSame( + $cache, + $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ) + ); + $this->assertSame( + $last_cache, + $this->get_driver_private_property( $driver, 'mysql_select_translation_last_cache' ) + ); + + $this->install_mysql_schema_metadata_fixture( + $driver, + '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' ) + ); + $this->assertNull( $this->get_driver_private_property( $driver, 'mysql_select_translation_last_cache' ) ); + } + + /** + * Tests query context keeps the first semantic token after hidden comments. + */ + public function test_query_context_first_token_ignores_hidden_comments(): void { + $driver = $this->create_backendless_driver(); + $query = "/* plugin preamble */\n-- runtime marker\nSELECT `ID` FROM `wptests_posts` WHERE `post_status` = 'publish'"; + + $context = $this->call_driver_private_method( $driver, 'get_mysql_query_context', array( $query ) ); + + $this->assertSame( WP_MySQL_Lexer::SELECT_SYMBOL, $context['first_token_id'] ); + $this->assertSame( WP_MySQL_Lexer::SELECT_SYMBOL, $context['tokens'][0]->id ); + $this->assertSame( 'SELECT', strtoupper( $context['tokens'][0]->get_bytes() ) ); + } + + /** + * Tests query context tokenization follows SQL mode changes. + */ + public function test_query_context_respects_sql_mode_changes(): void { + $driver = $this->create_backendless_driver(); + + $default_context = $this->call_driver_private_method( $driver, 'get_mysql_query_context', array( 'SELECT "quoted_name"' ) ); + $this->assertSame( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, $default_context['tokens'][1]->id ); + + $driver->set_sql_mode( 'ANSI_QUOTES' ); + + $ansi_context = $this->call_driver_private_method( $driver, 'get_mysql_query_context', array( 'SELECT "quoted_name"' ) ); + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $ansi_context['tokens'][1]->id ); + $this->assertSame( 'quoted_name', $ansi_context['tokens'][1]->get_value() ); + } + + /** + * Tests SELECT clause indexing ignores nested parentheses and subqueries. + */ + public function test_query_context_select_clause_index_ignores_nested_parentheses(): void { + $driver = $this->create_backendless_driver(); + $query = "SELECT IF(id IN (SELECT post_id FROM nested_posts WHERE flag = 1), title, 'fallback') AS label + FROM wptests_posts + WHERE id IN (SELECT post_id FROM wptests_postmeta WHERE meta_key = 'featured') + GROUP BY label + HAVING COUNT(*) > 0 + ORDER BY label + LIMIT 5"; + + $context = $this->call_driver_private_method( $driver, 'get_mysql_query_context', array( $query ) ); + $reader = Closure::bind( + function ( array $bound_context ): ?array { + $statement_end = $this->get_mysql_query_context_statement_end_position( $bound_context, 1 ); + return $this->get_mysql_query_context_select_clause_positions( $bound_context, 1, $statement_end ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + $clauses = $reader( $context ); + + $this->assertIsArray( $clauses ); + $this->assertSame( WP_MySQL_Lexer::FROM_SYMBOL, $context['tokens'][ $clauses['from_position'] ]->id ); + $this->assertSame( WP_MySQL_Lexer::WHERE_SYMBOL, $context['tokens'][ $clauses['where_position'] ]->id ); + $this->assertSame( WP_MySQL_Lexer::GROUP_SYMBOL, $context['tokens'][ $clauses['group_position'] ]->id ); + $this->assertSame( WP_MySQL_Lexer::HAVING_SYMBOL, $context['tokens'][ $clauses['having_position'] ]->id ); + $this->assertSame( WP_MySQL_Lexer::ORDER_SYMBOL, $context['tokens'][ $clauses['order_position'] ]->id ); + $this->assertSame( WP_MySQL_Lexer::LIMIT_SYMBOL, $context['tokens'][ $clauses['limit_position'] ]->id ); + + $first_from_position = null; + foreach ( $context['tokens'] as $position => $token ) { + if ( WP_MySQL_Lexer::FROM_SYMBOL === $token->id ) { + $first_from_position = $position; + break; + } + } + + $this->assertNotNull( $first_from_position ); + $this->assertLessThan( $clauses['from_position'], $first_from_position ); + } + + /** + * Tests commented SELECT and DML statements still dispatch through MySQL rewrites. + */ + public function test_query_context_preserves_commented_select_and_dml_dispatch(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_query_context (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + $insert = "/* plugin preamble */\nINSERT INTO `wptests_query_context` (`id`, `value`) VALUES (1, 'first')"; + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_query_context" ("id", "value") VALUES (1, \'first\')', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + + $select = "/* plugin preamble */\n-- runtime marker\nSELECT `id`, `value` FROM `wptests_query_context` WHERE (`id` IN (SELECT 1)) ORDER BY `id`"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'first', $rows[0]->value ); + $this->assertSame( + 'SELECT "id", "value" FROM "wptests_query_context" WHERE ("id" IN (SELECT 1)) ORDER BY "id"', + $this->remove_real_pgsql_test_schema_qualifiers( $this->get_last_single_postgresql_sql( $driver ) ) + ); + } + + /** + * Tests usermeta priming SELECT templates reuse shape without stale literals. + */ + public function test_usermeta_priming_select_template_cache_reuses_shape_without_stale_literals(): void { + $driver = $this->create_backendless_driver(); + + $first = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (11) ORDER BY umeta_id ASC' + ); + + $this->assertIsArray( $first ); + $this->assertTrue( $first['translated'] ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (11) ORDER BY umeta_id ASC', + $first['sql'] + ); + + $cache = $this->get_driver_private_property( $driver, 'mysql_meta_priming_select_template_cache' ); + $this->assertCount( 1, $cache ); + + $template = reset( $cache ); + $this->assertIsArray( $template ); + $this->assertStringNotContainsString( '11', $template['prefix_sql'] . $template['suffix_sql'] ); + + $second = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (27) ORDER BY umeta_id ASC' + ); + + $this->assertIsArray( $second ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (27) ORDER BY umeta_id ASC', + $second['sql'] + ); + $this->assertStringNotContainsString( '(11)', $second['sql'] ); + $this->assertSame( + $cache, + $this->get_driver_private_property( $driver, 'mysql_meta_priming_select_template_cache' ) + ); + } + + /** + * Tests placeholder and literal usermeta priming SELECT templates are distinct. + */ + public function test_usermeta_priming_select_template_cache_separates_placeholder_and_literal_slots(): void { + $driver = $this->create_backendless_driver(); + + $literal = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (42) ORDER BY umeta_id ASC' + ); + $placeholder = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (?) ORDER BY umeta_id ASC' + ); + + $this->assertIsArray( $literal ); + $this->assertIsArray( $placeholder ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (42) ORDER BY umeta_id ASC', + $literal['sql'] + ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (?) ORDER BY umeta_id ASC', + $placeholder['sql'] + ); + $this->assertCount( + 2, + $this->get_driver_private_property( $driver, 'mysql_meta_priming_select_template_cache' ) + ); + } + + /** + * Tests multi-slot usermeta priming SELECT templates preserve slot order and count. + */ + public function test_usermeta_priming_select_template_cache_preserves_multi_slot_lists(): void { + $driver = $this->create_backendless_driver(); + + $placeholders = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (?, ?) ORDER BY umeta_id ASC' + ); + $literals = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (7, 3, 11) ORDER BY umeta_id ASC' + ); + + $this->assertIsArray( $placeholders ); + $this->assertIsArray( $literals ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (?, ?) ORDER BY umeta_id ASC', + $placeholders['sql'] + ); + $this->assertSame( + 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (7, 3, 11) ORDER BY umeta_id ASC', + $literals['sql'] + ); + $this->assertCount( + 2, + $this->get_driver_private_property( $driver, 'mysql_meta_priming_select_template_cache' ) + ); + } + + /** + * Tests quoted identifiers and prefixed usermeta table names preserve translator output. + */ + public function test_usermeta_priming_select_template_cache_preserves_quoted_prefixed_table_translation(): void { + $driver = $this->create_backendless_driver(); + $query = 'SELECT `user_id`, `meta_key`, `meta_value` FROM `wptests_usermeta` WHERE `user_id` IN (4, 5) ORDER BY `umeta_id` ASC'; + + $template_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_select_query_translation', + $query + ); + + $this->assertIsArray( $template_translation ); + $this->assertSame( + 'SELECT "user_id", "meta_key", "meta_value" FROM "wptests_usermeta" WHERE "user_id" IN (4, 5) ORDER BY "umeta_id" ASC', + $template_translation['sql'] + ); + } + + /** + * Tests usermeta priming SELECT near misses stay on the normal translation path. + */ + public function test_usermeta_priming_select_template_cache_rejects_near_misses(): void { + $driver = $this->create_backendless_driver(); + + $queries = array( + 'LIMIT 0, 1' => 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (1) ORDER BY umeta_id ASC LIMIT 0, 1', + 'missing ORDER BY' => 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (1)', + 'table alias' => 'SELECT user_id, meta_key, meta_value FROM wp_usermeta AS um WHERE user_id IN (1) ORDER BY umeta_id ASC', + 'AND predicate' => "SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (1) AND meta_key = 'role' ORDER BY umeta_id ASC", + 'mismatched order column' => 'SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id IN (1) ORDER BY meta_id ASC', + ); + + foreach ( $queries as $label => $query ) { + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'get_mysql_usermeta_priming_select_template_translation', + $query + ); + + $this->assertNull( $translation, $label ); + $this->assertSame( + array(), + $this->get_driver_private_property( $driver, 'mysql_meta_priming_select_template_cache' ), + $label + ); + } + } + + /** + * 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'] ); + + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 overlapping SELECT shapes keep explicit translator precedence. + */ + public function test_select_translation_pipeline_preserves_overlapping_special_case_order(): void { + $driver = $this->create_backendless_driver(); + + $row_locking = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + 'SELECT SQL_CALC_FOUND_ROWS id FROM wptests_posts LIMIT 1 FOR UPDATE' + ); + + $this->assertIsArray( $row_locking ); + $this->assertTrue( $row_locking['translated'] ); + $this->assertSame( + 'SELECT id FROM wptests_posts LIMIT 1', + $row_locking['sql'] + ); + + $information_schema = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + 'SELECT TABLE_NAME FROM information_schema.TABLES ORDER BY TABLE_NAME LIMIT 1' + ); + + $this->assertIsArray( $information_schema ); + $this->assertTrue( $information_schema['translated'] ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $information_schema['sql'] ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $information_schema['sql'] ); + + $aggregate_sql_calc = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + 'SELECT SQL_CALC_FOUND_ROWS COUNT(*) AS c FROM t ORDER BY id ASC LIMIT 1' + ); + + $this->assertIsArray( $aggregate_sql_calc ); + $this->assertTrue( $aggregate_sql_calc['translated'] ); + $this->assertSame( 'SELECT COUNT (*) AS c FROM t LIMIT 1', $aggregate_sql_calc['sql'] ); + + $last_insert_id = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + 'SELECT LAST_INSERT_ID(17) AS assigned_id, LAST_INSERT_ID() AS readback' + ); + + $this->assertIsArray( $last_insert_id ); + $this->assertSame( 17, $last_insert_id['last_insert_id'] ); + $this->assertSame( 'SELECT 17 AS assigned_id, 17 AS readback', $last_insert_id['sql'] ); + + $distinct_sql_calc = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + 'SELECT DISTINCT SQL_CALC_FOUND_ROWS user_id FROM wptests_usermeta LIMIT 5' + ); + + $this->assertIsArray( $distinct_sql_calc ); + $this->assertTrue( $distinct_sql_calc['translated'] ); + $this->assertSame( 'SELECT DISTINCT user_id FROM wptests_usermeta LIMIT 5', $distinct_sql_calc['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(); + $this->install_mysql_schema_metadata_fixture( + $driver, + '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(); + + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_left_ids ( + `ID` bigint(20) NOT NULL, + `label` varchar(20) NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 RAND() and literal RAND(seed) are translated for PostgreSQL. + */ + public function test_rand_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_links (link_id INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (2)' ); + + $rows = $driver->query( 'SELECT link_id FROM wptests_links ORDER BY RAND(7) LIMIT 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT link_id FROM wptests_links ORDER BY CAST(0.90650219368422613 AS double precision) LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + "SELECT RAND(0) AS r0, + RAND(1) AS r1, + RAND(5) AS r5, + RAND(NULL) AS rnull, + RAND(3.9) AS rfloat, + RAND('5') AS rstring, + RAND('abc') AS rbadstring" + ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->r0, 1e-12 ); + $this->assertEqualsWithDelta( 0.40540353712197724, (float) $rows[0]->r1, 1e-12 ); + $this->assertEqualsWithDelta( 0.40613597483014313, (float) $rows[0]->r5, 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->rnull, 1e-12 ); + $this->assertEqualsWithDelta( 0.15595286540310166, (float) $rows[0]->rfloat, 1e-12 ); + $this->assertEqualsWithDelta( 0.40613597483014313, (float) $rows[0]->rstring, 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->rbadstring, 1e-12 ); + $this->assertSame( + 'SELECT CAST(0.15522042769493574 AS double precision) AS r0, CAST(0.40540353712197724 AS double precision) AS r1, CAST(0.40613597483014313 AS double precision) AS r5, CAST(0.15522042769493574 AS double precision) AS rnull, CAST(0.15595286540310166 AS double precision) AS rfloat, CAST(0.40613597483014313 AS double precision) AS rstring, CAST(0.15522042769493574 AS double precision) AS rbadstring', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT rand() AS random_value, RAND(link_id) AS seeded_value FROM wptests_links WHERE link_id = 1' ); + $this->assertEqualsWithDelta( 0.40540353712197724, (float) $rows[0]->seeded_value, 1e-12 ); + $this->assertStringContainsString( 'random() AS random_value', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertStringContainsString( '"__wp_pg_mysql_rand_seed"."seed" * 65537 + 55555555', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertStringContainsString( 'AS seeded_value', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertStringNotContainsString( 'random() AS seeded_value', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests MySQL SELECT row-locking clauses are stripped before PostgreSQL execution. + */ + public function test_select_row_locking_clauses_are_supported_noops(): void { + $queries = array( + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR SHARE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' LOCK IN SHARE MODE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE SKIP LOCKED", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE NOWAIT", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR SHARE OF wptests_locking NOWAIT", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_locking (name VARCHAR(255), value VARCHAR(255))' ); + $driver->query( "INSERT INTO wptests_locking (name, value) VALUES ('test_lock', '123')" ); + + $rows = $driver->query( $query ); + + $this->assertSame( '123', $rows[0]->value, $query ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT value FROM wptests_locking WHERE name = \'test_lock\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries(), + $query + ); + } + } + + /** + * Tests MySQL-only expression names inside string literals are not rewritten. + */ + public function test_expression_rewrite_does_not_replace_string_literals(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', 'CAST(meta_value AS UNSIGNED)', 'RAND()' AS literal_value"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', 'CAST(meta_value AS UNSIGNED)', 'RAND()' AS literal_value", + $sql + ); + } + + /** + * Tests SELECT DISTINCT term ID queries hide ORDER BY expressions. + */ + public function test_distinct_term_id_order_by_name_preserves_visible_projection_with_limit(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 20)' ); + + $select = "SELECT DISTINCT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy IN ('category') AND tr.object_id IN (1) + ORDER BY t.name ASC + LIMIT 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT t.term_id AS "term_id", MIN(t.name) AS "__wp_pg_order_0" FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 10', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped SELECT DISTINCT term ID queries hide ORDER BY expressions. + */ + public function test_grouped_distinct_term_id_order_by_name_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 20)' ); + + $select = "SELECT DISTINCT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy IN ('category') AND tr.object_id IN (1) + GROUP BY t.term_id + ORDER BY t.name ASC + LIMIT 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT DISTINCT t.term_id AS "term_id", MIN(t.name) AS "__wp_pg_order_0" FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 10', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped SELECT DISTINCT term query rows hide ORDER BY expressions. + */ + public function test_grouped_distinct_term_query_order_by_name_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL, parent INTEGER NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (10, 1, 'wptests_tax', 'Beta description', 0)" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (20, 2, 'wptests_tax', 'Alpha description', 0)" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (30, 1, 'other_tax', 'Other description', 0)" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (100, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (101, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (102, 'post', 'draft')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (100, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (101, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (102, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (100, 20)' ); + + $select = "SELECT DISTINCT t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent, COUNT(p.post_type) AS count + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id LEFT JOIN wptests_term_relationships AS r ON r.term_taxonomy_id = tt.term_taxonomy_id LEFT JOIN wptests_posts AS p ON p.ID = r.object_id + WHERE tt.taxonomy IN ('wptests_tax') AND (p.post_type = 'post' OR p.post_type IS NULL) AND (p.post_status = 'publish') + GROUP BY t.term_id ORDER BY t.name ASC"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( array( 'term_id', 'term_taxonomy_id', 'taxonomy', 'description', 'parent', 'count' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '20', $rows[0]->term_taxonomy_id ); + $this->assertSame( '1', $rows[0]->count ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( '10', $rows[1]->term_taxonomy_id ); + $this->assertSame( '2', $rows[1]->count ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id", "__wp_pg_distinct"."term_taxonomy_id" AS "term_taxonomy_id", "__wp_pg_distinct"."taxonomy" AS "taxonomy", "__wp_pg_distinct"."description" AS "description", "__wp_pg_distinct"."parent" AS "parent", "__wp_pg_distinct"."count" AS "count" FROM (SELECT DISTINCT t.term_id AS "term_id", tt.term_taxonomy_id AS "term_taxonomy_id", tt.taxonomy AS "taxonomy", tt.description AS "description", tt.parent AS "parent", COUNT (p.post_type) AS "count", MIN(t.name) AS "__wp_pg_order_0" FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id LEFT JOIN wptests_term_relationships AS r ON r.term_taxonomy_id = tt.term_taxonomy_id LEFT JOIN wptests_posts AS p ON p."ID" = r.object_id WHERE tt.taxonomy IN (\'wptests_tax\') AND (p.post_type = \'post\' OR p.post_type IS NULL) AND (p.post_status = \'publish\') GROUP BY t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress term cache priming preserves MySQL shared-term row order. + */ + public function test_wordpress_term_cache_priming_orders_shared_terms_by_term_taxonomy_id(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Shared')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Single')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 1, 'second_tax')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'first_tax')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (30, 2, 'single_tax')" ); + + $rows = $driver->query( + 'SELECT t.*, tt.* FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1,2)' + ); + + $this->assertSame( + array( '10', '20', '30' ), + array_map( + static function ( $row ): string { + return $row->term_taxonomy_id; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT t.*, tt.* FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1, 2) ORDER BY tt.term_taxonomy_id ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests SELECT DISTINCT term ID queries hide relationship order columns. + */ + public function test_distinct_term_id_order_by_term_order_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 10, 2)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 20, 1)' ); + + $rows = $driver->query( + "SELECT DISTINCT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy IN ('category') AND tr.object_id IN (1) + ORDER BY tr.term_order ASC + LIMIT 100" + ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT t.term_id AS "term_id", MIN(tr.term_order) AS "__wp_pg_order_0" FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 100', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests parenthesized user ID DISTINCT queries keep user_login ordering hidden. + */ + public function test_distinct_parenthesized_user_id_order_by_hides_login_order_column(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'zeta\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'alpha\')' ); + + $rows = $driver->query( 'SELECT DISTINCT(wptests_users.ID) FROM wptests_users WHERE 1=1 ORDER BY user_login LIMIT 0, 50' ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( '1', $rows[1]->ID ); + $this->assertSame( array( 'ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."ID" AS "ID" FROM (SELECT (wptests_users."ID") AS "ID", MIN(user_login) AS "__wp_pg_order_0" FROM wptests_users WHERE 1 = 1 GROUP BY (wptests_users."ID")) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 50 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests unsupported DISTINCT SELECT modifiers do not enter the grouped rewrite. + */ + public function test_distinct_order_by_unsupported_select_modifier_fails_closed(): void { + $driver = $this->create_driver(); + $modifiers = array( + 'HIGH_PRIORITY', + 'SQL_BIG_RESULT', + 'SQL_BUFFER_RESULT', + 'SQL_CACHE', + 'SQL_NO_CACHE', + 'SQL_SMALL_RESULT', + 'STRAIGHT_JOIN', + ); + + foreach ( $modifiers as $modifier ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + sprintf( + 'SELECT DISTINCT %s t.term_id FROM wptests_terms AS t ORDER BY t.name ASC', + $modifier + ) + ); + + $this->assertNull( $sql, sprintf( '%s should fall through unchanged.', $modifier ) ); + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT SQL_CALC_FOUND_ROWS HIGH_PRIORITY t.term_id FROM wptests_terms AS t ORDER BY t.name ASC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests keyword-like DISTINCT projection aliases are matched in ORDER BY. + */ + public function test_distinct_order_by_keyword_projection_alias_preserves_projected_order(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2023)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2024)' ); + + $rows = $driver->query( 'SELECT DISTINCT ID AS year FROM wptests_users ORDER BY year DESC' ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2024', $rows[0]->year ); + $this->assertSame( '2023', $rows[1]->year ); + $this->assertSame( array( 'year' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT DISTINCT "ID" AS year FROM wptests_users ORDER BY year DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests date archive aliases can satisfy DISTINCT ORDER BY references. + */ + public function test_distinct_date_archive_keyword_projection_aliases_match_order_by_items(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM wptests_posts + ORDER BY year DESC, month DESC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests date archive DISTINCT queries order by hidden aggregate post dates. + */ + public function test_distinct_date_archive_order_by_uses_hidden_aggregate_sort_column(): void { + $driver = $this->create_driver(); + + $select = "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM wptests_posts + WHERE post_type = 'foo' + AND post_status != 'auto-draft' AND post_status != 'trash' + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_distinct_order_by_query', $select ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $this->assertSame( + 'SELECT "__wp_pg_distinct"."year" AS "year", "__wp_pg_distinct"."month" AS "month" FROM (SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", MAX(post_date) AS "__wp_pg_order_0" FROM wptests_posts WHERE post_type = \'foo\' AND post_status != \'auto-draft\' AND post_status != \'trash\' GROUP BY ' . $year_sql . ', ' . $month_sql . ') AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" DESC', + $sql + ); + + $outer_projection = substr( $sql, 0, strpos( $sql, ' FROM (' ) ); + $this->assertStringNotContainsString( '__wp_pg_order_0', $outer_projection ); + $this->assertStringNotContainsString( 'SELECT DISTINCT', $sql ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS SELECT queries are translated for PostgreSQL. + */ + public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\'))) ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests FOUND_ROWS supports aliases used by WooCommerce report queries. + */ + public function test_found_rows_query_supports_alias_projection(): 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, 'shop_order', 'wc-completed', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (2, 'shop_order', 'wc-completed', '2024-01-02 00:00:00')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'shop_order' + ORDER BY wptests_posts.ID ASC + LIMIT 0, 1" + ); + + $this->assertCount( 1, $rows ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertSame( '2', $found_rows[0]->found_rows ); + $this->assertSame( 'found_rows', $driver->get_last_column_meta()[0]['name'] ); + } + + /** + * 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 FOUND_ROWS() can be rewritten inside scalar expressions without stale cached SQL. + */ + public function test_found_rows_runtime_function_rewrites_inside_scalar_expressions(): 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, 'page', 'publish')" ); + + $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_status = 'publish' + ORDER BY wptests_posts.ID ASC + LIMIT 0, 1" + ); + + $first = $driver->query( 'SELECT FOUND_ROWS() + 1 AS found_rows_plus_one, FOUND_ROWS() AS found_rows_value' ); + + $this->assertSame( '4', $first[0]->found_rows_plus_one ); + $this->assertSame( '3', $first[0]->found_rows_value ); + $this->assertSame( + 'SELECT 3 + 1 AS found_rows_plus_one, 3 AS found_rows_value', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $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 0, 1" + ); + + $second = $driver->query( 'SELECT FOUND_ROWS() + 1 AS found_rows_plus_one, FOUND_ROWS() AS found_rows_value' ); + + $this->assertSame( '3', $second[0]->found_rows_plus_one ); + $this->assertSame( '2', $second[0]->found_rows_value ); + $this->assertSame( + 'SELECT 2 + 1 AS found_rows_plus_one, 2 AS found_rows_value', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * 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 grouped postmeta value-only queries preserve MySQL case-insensitive LIKE behavior. + */ + public function test_grouped_postmeta_value_like_without_key_uses_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (3, 'post', 'publish', '2024-01-03 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'address', '123 Lorem St.')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'city', 'Loren')" ); + + $rows = $driver->query( + "SELECT wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( ( wptests_postmeta.meta_value LIKE '%lorem%' ) ) + AND wptests_posts.post_type = 'post' + AND ( ( wptests_posts.post_status = 'publish' ) ) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 5" + ); + + $this->assertSame( + array( '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertStringContainsString( + "LOWER(wptests_postmeta.meta_value) LIKE LOWER('%lorem%')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests numeric literals in predicate context use MySQL truthiness. + */ + public function test_numeric_literal_predicates_use_mysql_truthiness_without_changing_values_or_limits(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_date TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (1, \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (7, \'2024-01-07 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (9, \'2024-01-09 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (10, \'2024-01-10 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (11, \'2024-01-11 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (12, \'2024-01-12 00:00:00\')' ); + + $rows = $driver->query( + 'SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1=1 AND 0 + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10' + ); + + $this->assertSame( array(), $rows ); + $this->assertStringContainsString( 'WHERE 1 = 1 AND (0 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $between_rows = $driver->query( 'SELECT ID FROM wptests_posts WHERE ID BETWEEN 9 AND 11 ORDER BY ID ASC' ); + $this->assertSame( + array( '9', '10', '11' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $between_rows + ) + ); + $this->assertStringContainsString( 'WHERE "ID" BETWEEN 9 AND 11', $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( '(11 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $date_between_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ID FROM wptests_posts WHERE DAYOFMONTH(post_date) BETWEEN 9 AND 11' + ); + $this->assertStringContainsString( ' BETWEEN 9 AND 11', $date_between_sql ); + $this->assertStringNotContainsString( '(11 <> 0)', $date_between_sql ); + + $in_rows = $driver->query( 'SELECT ID FROM wptests_posts WHERE ID IN (7) ORDER BY ID ASC' ); + $this->assertCount( 1, $in_rows ); + $this->assertSame( '7', $in_rows[0]->ID ); + $this->assertStringContainsString( 'WHERE "ID" IN (7)', $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( '(7 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $selected_zero = $driver->query( 'SELECT 0' ); + $this->assertSame( '0', $selected_zero[0]->{'0'} ); + $this->assertSame( 'SELECT 0', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $limit_zero = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY ID LIMIT 0' ); + $this->assertSame( array(), $limit_zero ); + $this->assertSame( 'SELECT "ID" FROM wptests_posts ORDER BY "ID" LIMIT 0', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests correlated subquery identifiers resolve through table metadata casing. + */ + public function test_correlated_subquery_post_id_identifier_uses_metadata_casing(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'target', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'target', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'blocked', '1')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( + NOT EXISTS ( + SELECT 1 FROM wptests_postmeta mt1 + WHERE mt1.post_ID = wptests_postmeta.post_ID + AND mt1.meta_key = 'blocked' + LIMIT 1 + ) + AND wptests_postmeta.meta_value = 'abc' + ) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'mt1.post_id = wptests_postmeta.post_id', $sql ); + $this->assertStringNotContainsString( 'post_ID', $sql ); + $this->assertStringContainsString( 'wptests_posts."ID"', $sql ); + } + + /** + * Tests DECIMAL casts use text only for LIKE predicates. + */ + public function test_decimal_cast_like_uses_text_without_changing_numeric_comparisons(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'decimal_value', '10.30')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'decimal_value', '10.40')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) LIKE '%.3%' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertStringContainsString( 'AS text) LIKE', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $numeric_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) > 10.35 + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $numeric_rows ); + $this->assertSame( '2', $numeric_rows[0]->ID ); + $this->assertStringNotContainsString( 'AS text) >', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests FOUND_ROWS count queries preserve MySQL token adjacency before translation. + */ + public function test_found_rows_count_source_preserves_mysql_cast_and_regexp_tokens(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + + $unsigned_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'num_as_longtext' + AND CAST(wptests_postmeta.meta_value AS UNSIGNED) > '0' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS UNSIGNED) ASC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( + $this->get_expected_mysql_integer_cast_sql( 'wptests_postmeta.meta_value' ) . " > '0'", + $unsigned_count_sql + ); + $this->assertStringNotContainsString( 'UNSIGNED', $unsigned_count_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $unsigned_count_sql ); + $this->assertStringNotContainsString( 'LIMIT', $unsigned_count_sql ); + + $binary_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND CAST(wptests_postmeta.meta_key AS BINARY) REGEXP BINARY 'AAA_FOO_.*' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( 'CAST(wptests_postmeta.meta_key AS text) ~', $binary_count_sql ); + $this->assertStringNotContainsString( 'BINARY', $binary_count_sql ); + $this->assertStringNotContainsString( 'REGEXP', $binary_count_sql ); + + $decimal_like_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) LIKE '%.3%' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( 'AS text) LIKE', $decimal_like_count_sql ); + $this->assertStringNotContainsString( 'DECIMAL (10, 2)) LIKE', $decimal_like_count_sql ); + } + + /** + * Tests unsupported grouped DISTINCT ORDER BY shapes fail closed. + */ + public function test_distinct_grouped_order_by_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + + $term_query = 'SELECT DISTINCT t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent, COUNT(p.post_type) AS count + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id LEFT JOIN wptests_term_relationships AS r ON r.term_taxonomy_id = tt.term_taxonomy_id LEFT JOIN wptests_posts AS p ON p.ID = r.object_id + WHERE %s + GROUP BY t.term_id ORDER BY %s'; + $unsupported_queries = array( + 'SELECT DISTINCT t.term_id, COUNT(*) AS term_tt_count FROM wptests_terms AS t GROUP BY t.term_id ORDER BY t.name ASC', + 'SELECT DISTINCT t.term_id FROM wptests_terms AS t GROUP BY t.term_id, t.slug ORDER BY t.name ASC', + 'SELECT DISTINCT t.term_id FROM wptests_terms AS t GROUP BY t.term_id ORDER BY COUNT(*) DESC', + sprintf( + $term_query, + "tt.taxonomy IN ('wptests_tax', 'category') AND (p.post_status = 'publish')", + 't.name ASC' + ), + sprintf( + $term_query, + "(p.post_status = 'publish')", + 't.name ASC' + ), + "SELECT DISTINCT t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent, tt.count, COUNT(p.post_type) AS count + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id LEFT JOIN wptests_term_relationships AS r ON r.term_taxonomy_id = tt.term_taxonomy_id LEFT JOIN wptests_posts AS p ON p.ID = r.object_id + WHERE tt.taxonomy IN ('wptests_tax') AND (p.post_status = 'publish') + GROUP BY t.term_id ORDER BY t.name ASC", + sprintf( + $term_query, + "tt.taxonomy IN ('wptests_tax') AND (p.post_status = 'publish')", + 'p.post_date DESC' + ), + ); + + foreach ( $unsupported_queries as $query ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $query + ); + + $this->assertNull( $sql ); + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT wptests_users.ID FROM wptests_users WHERE wptests_users.ID IN (SELECT user_id FROM wptests_usermeta) ORDER BY user_login ASC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests grouped HAVING predicates can reference aggregate projection aliases. + */ + public function test_grouped_having_aggregate_alias_is_translated_for_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Parent')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Single')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (11, 1, 'post_tag')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (12, 2, 'category')" ); + + $rows = $driver->query( + 'SELECT tt.term_id, t.*, count(*) as term_tt_count FROM wptests_term_taxonomy tt + LEFT JOIN wptests_terms t ON t.term_id = tt.term_id + GROUP BY t.term_id + HAVING term_tt_count > 1 + LIMIT 1' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', (string) $rows[0]->term_id ); + $this->assertSame( '2', (string) $rows[0]->term_tt_count ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'GROUP BY t.term_id, tt.term_id', $sql ); + $this->assertStringContainsString( 'HAVING (count (*)) > 1', $sql ); + $this->assertStringNotContainsString( 'HAVING term_tt_count', $sql ); + } + + /** + * Tests grouped HAVING aliases can extend GROUP BY using safe inner-join equalities. + */ + public function test_grouped_having_inner_join_projection_extension_is_translated_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT tt.term_id, count(*) AS term_tt_count FROM wptests_term_taxonomy tt INNER JOIN wptests_terms t ON t.term_id = tt.term_id GROUP BY t.term_id HAVING term_tt_count > 1' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'GROUP BY t.term_id, tt.term_id', $sql ); + $this->assertStringContainsString( 'HAVING (count (*)) > 1', $sql ); + } + + /** + * Tests grouped HAVING identifiers that are not aliases fail closed. + */ + public function test_grouped_having_non_alias_identifier_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT term_id, COUNT(*) AS term_tt_count FROM wptests_term_taxonomy GROUP BY term_id HAVING missing_alias > 1' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests OR-scoped join equalities are not used for GROUP BY extensions. + */ + public function test_grouped_having_or_join_equality_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT a.id, b.id AS bid, COUNT(*) AS c FROM a LEFT JOIN b ON a.id = b.id OR b.flag = 1 GROUP BY a.id HAVING c > 0' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests nullable-side GROUP BY semantics from outer joins fail closed. + */ + public function test_grouped_having_outer_join_nullable_grouping_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT tt.term_id, COUNT(*) AS c FROM tt LEFT JOIN t ON t.term_id = tt.term_id GROUP BY t.term_id HAVING c > 1' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests nested predicate equalities are not used for GROUP BY extensions. + */ + public function test_grouped_having_nested_equality_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT a.id, b.id AS bid, COUNT(*) AS c FROM a, b WHERE (a.id = b.id) GROUP BY a.id HAVING c > 0' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests non-grouped non-aggregate projection aliases are not rewritten in HAVING. + */ + public function test_grouped_having_unsupported_projection_alias_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + "SELECT term_id, name AS term_name FROM wptests_terms GROUP BY term_id HAVING term_name = 'Parent'" + ); + + $this->assertNull( $sql ); + } + + /** + * Tests scalar COUNT queries drop irrelevant ORDER BY clauses. + */ + public function test_aggregate_count_order_by_is_dropped_for_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (1, \'2024-01-03 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (2, \'2024-01-01 00:00:00\', \'0\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (3, \'2024-01-02 00:00:00\', \'spam\')' ); + + $rows = $driver->query( + "SELECT COUNT(*) + FROM wptests_comments + WHERE comment_approved IN ('0', '1') + ORDER BY wptests_comments.comment_date_gmt ASC + LIMIT 0,3" + ); + + $this->assertSame( '2', array_values( get_object_vars( $rows[0] ) )[0] ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT COUNT (*) FROM wptests_comments WHERE comment_approved IN (\'0\', \'1\') LIMIT 3 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress role-count aggregates preserve ARRAY_N row shape. + */ + public function test_user_role_count_aggregate_projection_preserves_array_n_shape(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE wptests_usermeta (user_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (3)' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (1, \'wptests_capabilities\', \'a:1:{s:13:"administrator";b:1;}\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (2, \'wptests_capabilities\', \'a:1:{s:6:"editor";b:1;}\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (3, \'wptests_capabilities\', \'a:0:{}\')' ); + + $rows = $driver->query( + 'SELECT COUNT(NULLIF(`meta_value` LIKE \'%\"administrator\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"editor\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"author\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"contributor\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"subscriber\"%\', false)), + COUNT(NULLIF(`meta_value` = \'a:0:{}\', false)), + COUNT(*) + FROM wptests_usermeta + INNER JOIN wptests_users ON user_id = ID + WHERE meta_key = \'wptests_capabilities\'' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( '1', '1', '0', '0', '0', '1', '3' ), + array_values( get_object_vars( $rows[0] ) ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertSame( 7, substr_count( $sql, ' AS "' ) ); + $this->assertStringContainsString( 'COUNT (*) AS "COUNT (*)"', $sql ); + } + + /** + * Tests grouped date archive queries order by an aggregate post date. + */ + public function test_grouped_date_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ', ' . $month_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests yearly grouped archive queries order by an aggregate post date. + */ + public function test_grouped_year_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT YEAR(post_date) AS `year`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests unsupported DISTINCT grouped archive queries fail closed. + */ + public function test_distinct_count_grouped_year_archive_order_by_fails_closed(): void { + $driver = $this->create_driver(); + + $select = "SELECT DISTINCT count(ID) AS posts + FROM wptests_posts + WHERE post_type = 'post' + GROUP BY YEAR(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $this->assertNull( $sql ); + } + + /** + * Tests weekly grouped DISTINCT archive queries order by an aggregate post date. + */ + public function test_grouped_week_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT DISTINCT WEEK( `post_date`, 1 ) AS `week`, YEAR( `post_date` ) AS `yr`, DATE_FORMAT( `post_date`, '%Y-%m-%d' ) AS `yyyymmdd`, count( `ID` ) AS `posts` + FROM `wptests_posts` + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY WEEK( `post_date`, 1 ), YEAR( `post_date` ) + ORDER BY `post_date` DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $week_sql = $this->get_expected_mysql_week_mode_one_sql( '"post_date"' ); + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', '"post_date"' ); + $date_sql = $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'MAX("post_date")' ); + $this->assertSame( + 'SELECT ' . $week_sql . ' AS "week", ' . $year_sql . ' AS "yr", ' . $date_sql . ' AS "yyyymmdd", count ("ID") AS "posts" FROM "wptests_posts" WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $week_sql . ', ' . $year_sql . ' ORDER BY MAX("post_date") DESC', + $sql + ); + $this->assertStringNotContainsString( 'SELECT DISTINCT', $sql ); + $this->assertStringNotContainsString( 'WEEK', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests daily grouped archive queries order by an aggregate post date. + */ + public function test_grouped_day_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, DAYOFMONTH(post_date) AS `dayofmonth`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date), DAYOFMONTH(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $day_sql = $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", ' . $day_sql . ' AS "dayofmonth", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ', ' . $month_sql . ', ' . $day_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests grouped comment ID queries order by aggregate meta values. + */ + public function test_grouped_comment_meta_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE wptests_commentmeta (comment_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (2)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (3)' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (1, \'foo\', \'aaa\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (2, \'foo\', \'zzz\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (3, \'foo\', \'jjj\')' ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE wptests_commentmeta.meta_key = 'foo' + GROUP BY wptests_comments.comment_ID + ORDER BY CAST(wptests_commentmeta.meta_value AS CHAR) DESC, wptests_comments.comment_ID DESC" + ); + + $this->assertSame( + array( '2', '3', '1' ), + array_map( + static function ( $row ): string { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( array( 'comment_ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments INNER JOIN wptests_commentmeta ON (wptests_comments."comment_ID" = wptests_commentmeta.comment_id) WHERE wptests_commentmeta.meta_key = \'foo\' GROUP BY wptests_comments."comment_ID" ORDER BY MAX(CAST(wptests_commentmeta.meta_value AS text)) DESC, wptests_comments."comment_ID" DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped comment ID queries aggregate comment date secondary ordering. + */ + public function test_grouped_comment_meta_secondary_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, comment_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_commentmeta (comment_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (1, \'2015-01-28 03:00:00\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (2, \'2015-01-28 05:00:00\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (3, \'2015-01-28 03:00:00\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (1, \'foo\', \'jjj\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (2, \'foo\', \'zzz\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (3, \'foo\', \'aaa\')' ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE wptests_commentmeta.meta_key = 'foo' + GROUP BY wptests_comments.comment_ID + ORDER BY wptests_comments.comment_date ASC, CAST(wptests_commentmeta.meta_value AS CHAR) ASC, wptests_comments.comment_ID ASC" + ); + + $this->assertSame( + array( '3', '1', '2' ), + array_map( + static function ( $row ): string { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments INNER JOIN wptests_commentmeta ON (wptests_comments."comment_ID" = wptests_commentmeta.comment_id) WHERE wptests_commentmeta.meta_key = \'foo\' GROUP BY wptests_comments."comment_ID" ORDER BY MIN(wptests_comments.comment_date) ASC, MIN(CAST(wptests_commentmeta.meta_value AS text)) ASC, wptests_comments."comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests normal non-aggregate SELECT ORDER BY shapes do not enter the strict rewrite. + */ + public function test_strict_order_by_rewrite_ignores_normal_select_order_by(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + 'SELECT comment_ID FROM wptests_comments ORDER BY comment_ID DESC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests MySQL date/time extraction functions are translated for PostgreSQL. + */ + public function test_mysql_date_time_extract_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, QUARTER(post_date) AS q, DAYOFYEAR(post_date) AS doy, DAYOFMONTH(post_date) AS d, DAY(post_date) AS day_value, HOUR(post_date) AS h, MINUTE(post_date) AS i, SECOND(post_date) AS s, MICROSECOND(post_date) AS us, EXTRACT(DAY FROM post_date) AS extracted_day, EXTRACT(QUARTER FROM post_date) AS extracted_quarter, EXTRACT(DAYOFYEAR FROM post_date) AS extracted_doy, EXTRACT(MICROSECOND FROM post_date) AS extracted_us FROM wptests_posts WHERE ID = 1'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ) . ' AS y, ' . $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ) . ' AS m, ' . $this->get_expected_zero_date_safe_extract_sql( 'QUARTER', 'post_date' ) . ' AS q, ' . $this->get_expected_zero_date_safe_extract_sql( 'DOY', 'post_date' ) . ' AS doy, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS d, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS day_value, ' . $this->get_expected_zero_date_safe_extract_sql( 'HOUR', 'post_date' ) . ' AS h, ' . $this->get_expected_zero_date_safe_extract_sql( 'MINUTE', 'post_date' ) . ' AS i, ' . $this->get_expected_zero_date_safe_extract_sql( 'SECOND', 'post_date' ) . ' AS s, ' . $this->get_expected_zero_date_safe_extract_sql( 'MICROSECOND', 'post_date' ) . ' AS us, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS extracted_day, ' . $this->get_expected_zero_date_safe_extract_sql( 'QUARTER', 'post_date' ) . ' AS extracted_quarter, ' . $this->get_expected_zero_date_safe_extract_sql( 'DOY', 'post_date' ) . ' AS extracted_doy, ' . $this->get_expected_zero_date_safe_extract_sql( 'MICROSECOND', 'post_date' ) . ' AS extracted_us 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, MICROSECOND(post_date) AS us FROM wptests_posts WHERE post_date = \'0000-00-00 00:00:00.123456\''; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CASE WHEN CAST(post_date AS text) = '' THEN NULL WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) = '0000'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( "THEN CASE WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 12 FOR 2) AS integer) ELSE 0 END", $sql ); + $this->assertStringContainsString( "THEN CAST(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}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING(CAST(post_date AS text) FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END AS integer)", $sql ); + $this->assertStringNotContainsString( 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y', $sql ); + $this->assertStringContainsString( 'CAST(EXTRACT(YEAR FROM CAST(CASE WHEN CAST(post_date AS text)', $sql ); + $this->assertStringContainsString( 'THEN NULL ELSE CAST(post_date AS text) END AS timestamp)', $sql ); + } + + /** + * Tests stored empty temporal values are safe for MySQL-compatible date functions. + */ + public function test_stored_empty_temporal_values_are_safe_for_mysql_date_functions(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wp_acid_dates ( + id int(11) NOT NULL, + note varchar(20) NOT NULL, + d date DEFAULT NULL, + dt datetime DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->get_connection()->query( + "INSERT INTO wp_acid_dates (id, note, d, dt) VALUES + (1, 'empty', '', ''), + (2, 'zero', '0000-00-00', '0000-00-00 00:00:00'), + (3, 'partial', '2020-00-15', '2020-00-15 13:04:05'), + (4, 'valid', '2024-05-06', '2024-05-06 07:08:09')" + ); + + $select = "SELECT note, d, dt, DATE_FORMAT(d, '%Y-%m-%d') AS formatted, YEAR(d) AS y, MONTH(d) AS m, DAYOFMONTH(d) AS day + FROM wp_acid_dates + ORDER BY id"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertNotNull( $sql ); + $d_text_sql = 'CAST(d AS text)'; + $empty_temporal_sql = $this->get_expected_empty_temporal_condition_sql( $d_text_sql ); + $zero_date_condition_sql = $this->get_expected_zero_date_condition_sql( $d_text_sql ); + + $this->assertStringContainsString( + sprintf( + "CASE WHEN %1\$s THEN NULL WHEN %2\$s THEN SUBSTRING(%3\$s FROM 1 FOR 10) ELSE TO_CHAR(%4\$s, 'YYYY-MM-DD') END AS formatted", + $empty_temporal_sql, + $zero_date_condition_sql, + $d_text_sql, + $this->get_expected_zero_date_safe_timestamp_sql( 'd' ) + ), + $sql + ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'd' ) . ' AS y', $sql ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'd' ) . ' AS m', $sql ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'd' ) . ' AS day', $sql ); + $this->assertStringContainsString( + sprintf( 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', $empty_temporal_sql, $zero_date_condition_sql, $d_text_sql ), + $sql + ); + $this->assertStringNotContainsString( + sprintf( 'CAST(CASE WHEN %1$s THEN NULL ELSE %2$s END AS timestamp)', $zero_date_condition_sql, $d_text_sql ), + $sql + ); + } + + /** + * Tests MySQL temporal expressions compare safely against text-backed datetime columns. + */ + public function test_mysql_temporal_expression_comparisons_against_text_backed_datetime_columns_use_text_ordering_for_postgresql(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wp_acid_dates ( + id int(11) NOT NULL, + note varchar(20) NOT NULL, + d date DEFAULT NULL, + dt datetime DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->get_connection()->query( + "INSERT INTO wp_acid_dates (id, note, d, dt) VALUES + (1, 'empty', '', ''), + (2, 'zero', '0000-00-00', '0000-00-00 00:00:00'), + (3, 'partial', '2020-00-15', '2020-00-15 13:04:05'), + (4, 'old', '2001-01-01', '2001-01-01 00:00:00'), + (5, 'future', '2999-01-01', '2999-01-01 00:00:00')" + ); + + $now_sql = "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"; + $date_sub_sql = $this->get_expected_date_arithmetic_sql( '-', $now_sql, '7', 'day' ); + $threshold_sql = $this->get_expected_temporal_expression_comparison_text_sql( $date_sub_sql, true ); + $datetime_text_sql = 'CAST(dt AS text)'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT COUNT(*) AS old_rows FROM wp_acid_dates WHERE DATE_SUB(NOW(), INTERVAL 7 DAY) > dt' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( $threshold_sql . ' > ' . $datetime_text_sql, $sql ); + $this->assertStringNotContainsString( ') > dt', $sql ); + $this->assertStringNotContainsString( 'CAST(dt AS timestamp)', $sql ); + + $reversed_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT COUNT(*) AS old_rows FROM wp_acid_dates WHERE dt < DATE_SUB(NOW(), INTERVAL 7 DAY)' + ); + + $this->assertNotNull( $reversed_sql ); + $this->assertStringContainsString( $datetime_text_sql . ' < ' . $threshold_sql, $reversed_sql ); + $this->assertStringNotContainsString( 'dt < (', $reversed_sql ); + $this->assertStringNotContainsString( 'CAST(dt AS timestamp)', $reversed_sql ); + } + + /** + * Tests literal MySQL zero-date extraction SQL guards the timestamp cast. + */ + public function test_mysql_date_time_extract_functions_guard_literal_zero_dates_for_postgresql(): void { + $driver = $this->create_driver(); + + $literals = array( + '0000-00-00 00:00:00', + '0000-00-00', + '2020-00-15 00:00:00', + '2020-01-00 00:00:00', + '2026-06-10 14:08:09', + ); + $extract_functions = array( + array( + 'name' => 'YEAR', + 'unit' => 'YEAR', + ), + array( + 'name' => 'MONTH', + 'unit' => 'MONTH', + ), + array( + 'name' => 'QUARTER', + 'unit' => 'QUARTER', + ), + array( + 'name' => 'DAYOFYEAR', + 'unit' => 'DOY', + ), + array( + 'name' => 'DAYOFMONTH', + 'unit' => 'DAY', + ), + array( + 'name' => 'DAY', + 'unit' => 'DAY', + ), + array( + 'name' => 'HOUR', + 'unit' => 'HOUR', + ), + array( + 'name' => 'MINUTE', + 'unit' => 'MINUTE', + ), + array( + 'name' => 'SECOND', + 'unit' => 'SECOND', + ), + array( + 'name' => 'MICROSECOND', + 'unit' => 'MICROSECOND', + ), + ); + + 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 + ); + if ( 'MICROSECOND' === $extract_function['unit'] ) { + $this->assertStringContainsString( + "CAST(TO_CHAR(CAST(CASE WHEN {$expression_text_sql}", + $sql, + $function_sql + ); + } else { + $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 + ); + if ( 'MICROSECOND' !== $extract_function['unit'] ) { + $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 unsupported MySQL EXTRACT units fail closed instead of passing through PostgreSQL semantics. + */ + public function test_mysql_extract_unsupported_units_fail_closed_for_postgresql(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT EXTRACT(WEEK FROM post_date) AS extracted_value' => 'EXTRACT(WEEK', + 'SELECT EXTRACT(YEAR_MONTH FROM post_date) AS extracted_value' => 'EXTRACT(YEAR_MONTH', + 'SELECT EXTRACT(DAY_HOUR FROM post_date) AS extracted_value' => 'EXTRACT(DAY_HOUR', + 'SELECT EXTRACT(DAY_SECOND FROM post_date) AS extracted_value' => 'EXTRACT(DAY_SECOND', + ); + + foreach ( $queries as $query => $raw_extract_sql ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ); + + $this->assertTrue( null === $sql || false === strpos( $sql, $raw_extract_sql ), $query ); + $this->assertNull( $sql, $query ); + } + } + + /** + * Tests unsupported MySQL EXTRACT units are rejected before backend execution. + */ + public function test_mysql_extract_unsupported_units_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT EXTRACT(WEEK FROM post_date) AS extracted_value', + 'SELECT EXTRACT(YEAR_MONTH FROM post_date) AS extracted_value', + 'SELECT EXTRACT(DAY_HOUR FROM post_date) AS extracted_value', + 'SELECT EXTRACT(DAY_SECOND FROM post_date) AS extracted_value', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported EXTRACT() unit to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported MySQL EXTRACT units with zero dates are rejected before backend execution. + */ + public function test_mysql_extract_unsupported_zero_date_units_fail_closed_before_backend_execution(): void { + $queries = array( + "SELECT EXTRACT(WEEK FROM '0000-00-00 00:00:00') AS extracted_value", + "SELECT EXTRACT(YEAR_MONTH FROM '2020-00-15 00:00:00') AS extracted_value", + "SELECT EXTRACT(DAY_HOUR FROM '0000-00-00') AS extracted_value", + "SELECT EXTRACT(DAY_SECOND FROM '2020-01-00 00:00:00.123456') AS extracted_value", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported zero-date EXTRACT() unit to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests MySQL 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, WEEKOFYEAR(post_date) AS week_of_year, DAYOFWEEK(post_date) AS day_of_week, WEEKDAY(post_date) AS weekday_value FROM wptests_posts WHERE WEEK(post_date, 1) = 24 AND WEEKOFYEAR(post_date) = 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_week_sql( 'post_date', 3 ) . ' AS week_of_year, ' . $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_week_sql( 'post_date', 3 ) . ' = 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( 'WEEKOFYEAR', $sql ); + $this->assertStringNotContainsString( 'DAYOFWEEK', $sql ); + $this->assertStringNotContainsString( 'WEEKDAY', $sql ); + } + + /** + * Tests MySQL WEEK modes supported by the PostgreSQL backend are translated. + */ + public function test_mysql_week_supported_modes_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $cases = array( + 'WEEK(post_date)' => 0, + 'WEEK(post_date, 0)' => 0, + 'WEEK(post_date, 1)' => 1, + 'WEEK(post_date, 2)' => 2, + 'WEEK(post_date, 3)' => 3, + 'WEEK(post_date, 4)' => 4, + 'WEEK(post_date, 5)' => 5, + 'WEEK(post_date, 6)' => 6, + 'WEEK(post_date, 7)' => 7, + ); + + foreach ( $cases as $week_call => $mode ) { + $select = 'SELECT ' . $week_call . ' AS week_num FROM wptests_posts WHERE ' . $week_call . ' = 1'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + $week_sql = $this->get_expected_mysql_week_sql( 'post_date', $mode ); + + $this->assertSame( + 'SELECT ' . $week_sql . ' AS week_num FROM wptests_posts WHERE ' . $week_sql . ' = 1', + $sql, + $week_call + ); + $this->assertStringNotContainsString( 'WEEK(', $sql, $week_call ); + } + } + + /** + * Tests unsupported MySQL WEEK modes fail closed. + */ + public function test_mysql_week_unsupported_modes_fail_closed(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT WEEK(post_date, 8) AS week_num', + 'SELECT WEEK(post_date, -1) AS week_num', + 'SELECT WEEK(post_date, default_week_format) AS week_num', + 'SELECT WEEK(post_date, 1 + 1) AS week_num', + 'SELECT WEEK(post_date, 0, 1) AS week_num', + 'SELECT WEEKOFYEAR(post_date, 1) AS week_num', + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests unsupported MySQL WEEK modes are rejected before backend execution. + */ + public function test_mysql_week_unsupported_modes_fail_closed_before_backend_execution(): void { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( 'SELECT WEEK(post_date, 8) AS week_num' ); + $this->fail( 'Expected unsupported WEEK() mode to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL WEEK() mode.', $e->getMessage() ); + } + + $this->assertSame( 0, $connection->get_query_count() ); + } + + /** + * Tests lowercase MySQL date compatibility functions trigger translation. + */ + public function test_lowercase_mysql_date_compatibility_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT week(post_date, 1) AS week_num, dayofweek(post_date) AS day_of_week, weekday(post_date) AS weekday_value, date_format(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' AS week_num, ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' AS day_of_week, ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' AS weekday_value, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts', + $sql + ); + } + + /** + * Tests supported MySQL DATE_FORMAT calls are translated for PostgreSQL. + */ + public function test_mysql_date_format_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_FORMAT(post_date, '%H.%i') AS hour_minute, DATE_FORMAT(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts WHERE DATE_FORMAT(post_date, '%H.%i') >= 0.42"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' AS hour_minute, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts WHERE ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' >= 0.42', + $sql + ); + $this->assertStringContainsString( 'CAST(TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'HH24.MI') AS double precision)", $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'YYYY-MM-DD')", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests numeric MySQL DATE_FORMAT masks used by WordPress date queries are translated. + */ + public function test_mysql_date_format_numeric_masks_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_FORMAT(post_date, '%H.%i%s') AS hour_minute_second, DATE_FORMAT(post_date, '0.%i%s') AS minute_second_fraction FROM wptests_posts"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'HH24.MISS')", $sql ); + $this->assertStringContainsString( "'0.' || TO_CHAR(" . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'MISS')", $sql ); + $this->assertStringContainsString( 'AS hour_minute_second', $sql ); + $this->assertStringContainsString( 'AS minute_second_fraction', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests date compatibility function names inside string literals are not translated. + */ + public function test_mysql_date_compatibility_function_names_inside_literals_are_not_translated(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format", + $sql + ); + $this->assertStringNotContainsString( 'DATE_TRUNC', $sql ); + $this->assertStringNotContainsString( 'EXTRACT(DOW', $sql ); + $this->assertStringNotContainsString( 'TO_CHAR', $sql ); + } + + /** + * Tests generated WEEK, weekday, and DATE_FORMAT SQL guards zero-date timestamp casts. + */ + public function test_mysql_date_compatibility_functions_guard_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT WEEK('0000-00-00 00:00:00', 1) AS week_num, DAYOFWEEK('2020-00-15 13:05:00') AS day_of_week, WEEKDAY('2020-01-00 13:05:00') AS weekday_value, DATE_FORMAT('0000-00-00 13:05:00', '%H.%i') AS hour_minute, DATE_FORMAT('2020-00-15 13:05:00', '%Y-%m-%d') AS formatted_date"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-00-15 13:05:00' AS text) = '' OR CAST('2020-00-15 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-01-00 13:05:00' AS text) = '' OR CAST('2020-01-00 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN CAST(SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 12 FOR 2) || '.' || SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 15 FOR 2) AS double precision) ELSE 0 END", $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('2020-00-15 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-00-15 13:05:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-01-00 13:05:00' AS timestamp)", $sql ); + } + + /** + * Tests DATE_FORMAT derives numeric/time parts from zero-ish dates without timestamp casts. + */ + public function test_mysql_date_format_zero_date_numeric_and_time_specifiers_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00 13:04:05.123' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00 13:04:05.123', '%Y %y %m %c %d %e %D %H %k %h %I %l %i %s %S %T %r %p %f %% %q') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN SUBSTRING(' . $expression_sql . ' FROM 1 FOR 4)', $sql ); + $this->assertStringContainsString( 'SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2)', $sql ); + $this->assertStringContainsString( 'CAST(CAST(SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2) AS integer) AS text)', $sql ); + $this->assertStringContainsString( 'SUBSTRING(' . $expression_sql . ' FROM 9 FOR 2)', $sql ); + $this->assertStringContainsString( "CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( 'LPAD(CAST(MOD(CAST(CASE WHEN ' . $expression_sql, $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END AS integer) < 12 THEN 'AM' ELSE 'PM' END", $sql ); + $this->assertStringContainsString( "LEFT(RPAD(SUBSTRING($expression_sql FROM '[.]([0-9]+)'), 6, '0'), 6)", $sql ); + $this->assertStringContainsString( "'%'", $sql ); + $this->assertStringContainsString( "|| 'q'", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00 13:04:05.123' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests DATE_FORMAT keeps NULL semantics for zero-ish dates requiring a real calendar date. + */ + public function test_mysql_date_format_zero_date_calendar_specifiers_return_null_for_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00', '%Y %W') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN NULL ELSE', $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( "'2006-06-00'" ) . ", 'FMDay')", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests broader MySQL DATE_FORMAT specifiers are translated for PostgreSQL. + */ + public function test_mysql_date_format_extended_specifiers_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT(post_date, '%a %b %c %D %d %e %f %H %h %I %i %j %k %l %M %m %p %r %S %s %T %U %u %V %v %W %w %X %x %Y %y %%') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ); + $formats = array( + 'Dy', + 'Mon', + 'FMMM', + 'DD', + 'FMDD', + 'US', + 'HH24', + 'HH12', + 'MI', + 'DDD', + 'FMHH24', + 'FMHH12', + 'FMMonth', + 'MM', + 'AM', + 'HH12:MI:SS AM', + 'SS', + 'HH24:MI:SS', + 'IW', + 'FMDay', + 'YYYY', + 'IYYY', + 'YY', + ); + foreach ( $formats as $format ) { + $this->assertStringContainsString( 'TO_CHAR(' . $timestamp_sql . ", '" . $format . "')", $sql, $format ); + } + + $this->assertStringContainsString( 'CAST(EXTRACT(DAY FROM ' . $timestamp_sql . ') AS integer)', $sql ); + $this->assertStringContainsString( 'CAST(CAST(EXTRACT(DOW FROM ' . $timestamp_sql . ') AS integer) AS text)', $sql ); + $this->assertStringContainsString( "'%'", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests DATE_FORMAT week specifiers follow MySQL week modes. + */ + public function test_mysql_date_format_week_specifiers_follow_mysql_week_modes(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2021-01-03', '%U %u %V %v %X %x') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( "'2021-01-03'" ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_zero_sql( $timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_week_mode_one_timestamp_sql( $timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_two_sql( $timestamp_sql ) ), + $sql + ); + $this->assertSame( 1, substr_count( $sql, 'TO_CHAR(' . $timestamp_sql . ", 'IW')" ) ); + $this->assertStringContainsString( $this->get_expected_mysql_sunday_week_mode_two_year_sql( $timestamp_sql ), $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $timestamp_sql . ", 'IYYY')", $sql ); + $this->assertStringNotContainsString( 'TO_CHAR(' . $timestamp_sql . ", 'YYYY') || ' ' || TO_CHAR(" . $timestamp_sql . ", 'IW')", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests representative WordPress date archive queries do not reach PostgreSQL with raw MySQL functions. + */ + public function test_wordpress_date_query_extract_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT post_id FROM wptests_postmeta, wptests_posts + WHERE ID = post_id + AND post_type = 'post' + AND meta_key = '_wp_old_slug' + AND meta_value = 'foo-bar' + AND YEAR(post_date) = 2026 + AND MONTH(post_date) = 6 + AND DAYOFMONTH(post_date) = 10"; + + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT post_id FROM wptests_postmeta, wptests_posts WHERE "ID" = post_id AND post_type = \'post\' AND meta_key = \'_wp_old_slug\' AND meta_value = \'foo-bar\' AND ' . $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ) . ' = 2026 AND ' . $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ) . ' = 6 AND ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' = 10', + $sql + ); + } + + /** + * Tests WordPress DATE_ADD queries are translated for PostgreSQL. + */ + public function test_wordpress_date_add_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(comment_date_gmt, INTERVAL '0' SECOND) FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'comment_date_gmt', "'0'", 'second' ) . " FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1", + $sql + ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests DATE_SUB queries are detected even without another rewrite trigger. + */ + public function test_mysql_date_sub_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_SUB(post_date_gmt, INTERVAL 1 DAY) AS older'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '1', 'day' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'DATE_SUB', $sql ); + } + + /** + * Tests ADDDATE and SUBDATE aliases share DATE_ADD/DATE_SUB interval translation. + */ + public function test_mysql_adddate_and_subdate_aliases_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(post_date_gmt, INTERVAL 2 DAY) AS newer, SUBDATE(post_date_gmt, INTERVAL 3 HOUR) AS older' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', 'day' ) . ' AS newer, ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '3', 'hour' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + $this->assertStringNotContainsString( 'SUBDATE', $sql ); + } + + /** + * Tests ADDDATE and SUBDATE support MySQL's numeric day alias form. + */ + public function test_mysql_adddate_and_subdate_numeric_day_aliases_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(post_date_gmt, 2) AS newer, SUBDATE(post_date_gmt, 3) AS older' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', 'day' ) . ' AS newer, ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '3', 'day' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + $this->assertStringNotContainsString( 'SUBDATE', $sql ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(COALESCE(post_date_gmt, post_date), 1 + 1) AS shifted' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'COALESCE(post_date_gmt, post_date)', '1 + 1', 'day' ) . ' AS shifted', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + } + + /** + * Tests DATE_ADD supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_date_add_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => '3 months', + 'YEAR' => 'year', + 'SQL_TSI_MICROSECOND' => 'microsecond', + 'SQL_TSI_SECOND' => 'second', + 'SQL_TSI_MINUTE' => 'minute', + 'SQL_TSI_HOUR' => 'hour', + 'SQL_TSI_DAY' => 'day', + 'SQL_TSI_WEEK' => 'week', + 'SQL_TSI_MONTH' => 'month', + 'SQL_TSI_QUARTER' => '3 months', + 'SQL_TSI_YEAR' => 'year', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 2 ' . $mysql_unit . ') AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', $postgresql_unit ) . ' AS shifted', + $sql, + $mysql_unit + ); + } + } + + /** + * Tests TIMESTAMPADD supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_timestampadd_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => '3 months', + 'YEAR' => 'year', + 'SQL_TSI_MINUTE' => 'minute', + 'SQL_TSI_QUARTER' => '3 months', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = 'SELECT TIMESTAMPADD(' . $mysql_unit . ', 2, post_date_gmt) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', $postgresql_unit ) . ' AS shifted', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'TIMESTAMPADD', $sql, $mysql_unit ); + } + } + + /** + * Tests TIMESTAMPDIFF supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_timestampdiff_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => 'quarter', + 'YEAR' => 'year', + 'SQL_TSI_SECOND' => 'second', + 'SQL_TSI_QUARTER' => 'quarter', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = "SELECT TIMESTAMPDIFF($mysql_unit, '2001-02-01 12:59:59.123456', post_date_gmt) AS diff"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_timestampdiff_sql( $postgresql_unit, "'2001-02-01 12:59:59.123456'", 'post_date_gmt' ) . ' AS diff', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'TIMESTAMPDIFF', $sql, $mysql_unit ); + } + } + + /** + * Tests public TIMESTAMPDIFF queries allow generated PostgreSQL EXTRACT(EPOCH) SQL. + */ + public function test_mysql_timestampdiff_public_query_allows_generated_postgresql_extract_epoch(): void { + $driver = $this->create_real_pgsql_driver(); + + $rows = $driver->query( + "SELECT TIMESTAMPDIFF(SECOND, '2001-02-01 12:59:59.123456', '2001-02-01 13:00:01.123456') AS diff" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', (string) $rows[0]->diff ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'EXTRACT(EPOCH FROM', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'TIMESTAMPDIFF', $queries[0]['sql'] ); + } + + /** + * Tests TIMESTAMPDIFF guards zero-date timestamp casts for PostgreSQL. + */ + public function test_mysql_timestampdiff_guards_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT TIMESTAMPDIFF(DAY, '0000-00-00 00:00:00', post_date_gmt) AS diff"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_timestampdiff_sql( 'day', "'0000-00-00 00:00:00'", 'post_date_gmt' ) . ' AS diff', + $sql + ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CAST('0000-00-00 00:00:00' AS text) END AS timestamp", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + } + + /** + * Tests TIMESTAMPADD supports composite MySQL interval literal units for PostgreSQL. + */ + public function test_mysql_timestampadd_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( 'SECOND_MICROSECOND', "'10.09'", array( array( '10', 'second' ), array( '090000', 'microsecond' ) ) ), + array( 'MINUTE_SECOND', "'1:02'", array( array( '1', 'minute' ), array( '02', 'second' ) ) ), + array( 'MINUTE_MICROSECOND', "'3:04.005006'", array( array( '3', 'minute' ), array( '04', 'second' ), array( '005006', 'microsecond' ) ) ), + array( 'HOUR_MINUTE', "'5:30'", array( array( '5', 'hour' ), array( '30', 'minute' ) ) ), + array( 'HOUR_SECOND', "'6:07:08'", array( array( '6', 'hour' ), array( '07', 'minute' ), array( '08', 'second' ) ) ), + array( 'HOUR_MICROSECOND', "'9:10:11.000012'", array( array( '9', 'hour' ), array( '10', 'minute' ), array( '11', 'second' ), array( '000012', 'microsecond' ) ) ), + array( 'DAY_HOUR', "'13 14'", array( array( '13', 'day' ), array( '14', 'hour' ) ) ), + array( 'DAY_MINUTE', "'15 16:17'", array( array( '15', 'day' ), array( '16', 'hour' ), array( '17', 'minute' ) ) ), + array( 'DAY_SECOND', "'18 19:20:21'", array( array( '18', 'day' ), array( '19', 'hour' ), array( '20', 'minute' ), array( '21', 'second' ) ) ), + array( 'DAY_MICROSECOND', "'22 23:24:25.123456'", array( array( '22', 'day' ), array( '23', 'hour' ), array( '24', 'minute' ), array( '25', 'second' ), array( '123456', 'microsecond' ) ) ), + array( 'YEAR_MONTH', "'2-03'", array( array( '2', 'year' ), array( '03', 'month' ) ) ), + array( 'HOUR_MINUTE', '-1.5', array( array( '-1', 'hour' ), array( '-5', 'minute' ) ) ), + ); + + foreach ( $cases as $case ) { + list( $mysql_unit, $value_sql, $components ) = $case; + + $select = 'SELECT TIMESTAMPADD(' . $mysql_unit . ', ' . $value_sql . ', post_date_gmt) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( '+', 'post_date_gmt', $this->get_expected_mysql_composite_interval_sql( $components ) ) . ' AS shifted', + $sql, + $mysql_unit . ' ' . $value_sql + ); + $this->assertStringNotContainsString( 'TIMESTAMPADD', $sql, $mysql_unit . ' ' . $value_sql ); + } + } + + /** + * Tests fractional SECOND interval values preserve MySQL numeric coercion. + */ + public function test_mysql_date_arithmetic_preserves_fractional_second_intervals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 0.5 SECOND) AS shifted', + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '0.5', 'second' ) . ' AS shifted', + 'DATE_ADD', + ), + array( + "SELECT DATE_SUB(post_date_gmt, INTERVAL '0.25' SECOND) AS shifted", + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', "'0.25'", 'second' ) . ' AS shifted', + 'DATE_SUB', + ), + array( + 'SELECT TIMESTAMPADD(SECOND, 0.5, post_date_gmt) AS shifted', + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '0.5', 'second' ) . ' AS shifted', + 'TIMESTAMPADD', + ), + ); + + foreach ( $cases as $case ) { + list( $select, $expected, $function_name ) = $case; + + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( $expected, $sql, $select ); + $this->assertStringContainsString( ' AS numeric)', $sql, $select ); + $this->assertStringNotContainsString( $function_name, $sql, $select ); + } + } + + /** + * Tests DATE_ADD translates full MySQL composite interval literals exactly. + */ + public function test_mysql_date_add_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( 'SECOND_MICROSECOND', "'10.09'", array( array( '10', 'second' ), array( '090000', 'microsecond' ) ) ), + array( 'MINUTE_SECOND', "'1:02'", array( array( '1', 'minute' ), array( '02', 'second' ) ) ), + array( 'MINUTE_MICROSECOND', "'3:04.005006'", array( array( '3', 'minute' ), array( '04', 'second' ), array( '005006', 'microsecond' ) ) ), + array( 'HOUR_MINUTE', "'5:30'", array( array( '5', 'hour' ), array( '30', 'minute' ) ) ), + array( 'HOUR_SECOND', "'6:07:08'", array( array( '6', 'hour' ), array( '07', 'minute' ), array( '08', 'second' ) ) ), + array( 'HOUR_MICROSECOND', "'9:10:11.000012'", array( array( '9', 'hour' ), array( '10', 'minute' ), array( '11', 'second' ), array( '000012', 'microsecond' ) ) ), + array( 'DAY_HOUR', "'13 14'", array( array( '13', 'day' ), array( '14', 'hour' ) ) ), + array( 'DAY_MINUTE', "'15 16:17'", array( array( '15', 'day' ), array( '16', 'hour' ), array( '17', 'minute' ) ) ), + array( 'DAY_SECOND', "'18 19:20:21'", array( array( '18', 'day' ), array( '19', 'hour' ), array( '20', 'minute' ), array( '21', 'second' ) ) ), + array( 'DAY_MICROSECOND', "'22 23:24:25.123456'", array( array( '22', 'day' ), array( '23', 'hour' ), array( '24', 'minute' ), array( '25', 'second' ), array( '123456', 'microsecond' ) ) ), + array( 'YEAR_MONTH', "'2-03'", array( array( '2', 'year' ), array( '03', 'month' ) ) ), + ); + + foreach ( $cases as $case ) { + list( $mysql_unit, $value_sql, $components ) = $case; + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL ' . $value_sql . ' ' . $mysql_unit . ') AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( '+', 'post_date_gmt', $this->get_expected_mysql_composite_interval_sql( $components ) ) . ' AS shifted', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql, $mysql_unit ); + } + } + + /** + * Tests DATE_SUB translates MySQL composite interval literals exactly. + */ + public function test_mysql_date_sub_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_SUB(post_date_gmt, INTERVAL '1 02:03:04.005006' DAY_MICROSECOND) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '-', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'day' ), + array( '02', 'hour' ), + array( '03', 'minute' ), + array( '04', 'second' ), + array( '005006', 'microsecond' ), + ) + ) + ) . ' AS shifted', + $sql + ); + $this->assertStringNotContainsString( 'DATE_SUB', $sql ); + } + + /** + * Tests negative composite interval signs apply to every component. + */ + public function test_mysql_date_add_negative_composite_interval_sign_applies_to_all_parts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '-5:30' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '-5', 'hour' ), + array( '-30', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1.5 HOUR_MINUTE) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'hour' ), + array( '5', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD parsing handles nested expressions and lowercase interval syntax. + */ + public function test_mysql_date_add_handles_nested_and_lowercase_interval_arguments(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_ADD(COALESCE(post_date_gmt, post_date), interval (1 + 1) day) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'COALESCE(post_date_gmt, post_date)', '(1 + 1)', 'day' ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD supports WordPress upgrade HOUR_MINUTE interval values. + */ + public function test_mysql_date_add_supports_hour_minute_interval_values_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '5:30' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '5', 'hour' ), + array( '30', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + $this->assertStringContainsString( "INTERVAL '1 hour'", $sql ); + $this->assertStringContainsString( "INTERVAL '1 minute'", $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests composite interval literals support MySQL's right-aligned short values. + */ + public function test_mysql_date_add_supports_short_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 2 HOUR_MINUTE) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '2', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + + $select = "SELECT DATE_SUB(post_date_gmt, INTERVAL '1:2' DAY_SECOND) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '-', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'minute' ), + array( '2', 'second' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests composite interval literals accept alternate MySQL delimiters. + */ + public function test_mysql_date_add_supports_alternate_composite_interval_delimiters_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '6/4' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '6', 'hour' ), + array( '4', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD timestamp casts are guarded for MySQL zero-date values. + */ + public function test_mysql_date_add_guards_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD('0000-00-00 00:00:00', INTERVAL 1 DAY) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', "'0000-00-00 00:00:00'", '1', 'day' ) . ' AS shifted', + $sql + ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CAST('0000-00-00 00:00:00' AS text) END AS timestamp", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + } + + /** + * Tests invalid DATE_ADD interval units and unsafe composite shapes fail closed. + */ + public function test_mysql_date_add_with_unsupported_interval_shape_fails_closed(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT DATE_ADD(post_date_gmt, 1) AS shifted', + 'SELECT DATE_SUB(post_date_gmt, 1) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 fortnight) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 6/4 HOUR_MINUTE) AS shifted', + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1:2:3' MINUTE_SECOND) AS shifted", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests composite interval literals with overlong microseconds fail closed. + */ + public function test_mysql_date_arithmetic_rejects_overlong_composite_interval_microseconds_for_postgresql(): void { + $driver = $this->create_driver(); + $queries = array( + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1.1234567' SECOND_MICROSECOND) AS shifted", + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1 02:03:04.1234567' DAY_MICROSECOND) AS shifted", + "SELECT DATE_SUB(post_date_gmt, INTERVAL '1 02:03:04.1234567' DAY_MICROSECOND) AS shifted", + "SELECT TIMESTAMPADD(SECOND_MICROSECOND, '1.1234567', post_date_gmt) AS shifted", + "SELECT TIMESTAMPADD(DAY_MICROSECOND, '1 02:03:04.1234567', post_date_gmt) AS shifted", + ); + + foreach ( $queries as $query ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ); + + $this->assertTrue( null === $sql || false === strpos( $sql, "CAST('1234567' AS double precision) * INTERVAL '1 microsecond'" ), $query ); + $this->assertNull( $sql, $query ); + } + } + + /** + * Tests overlong composite interval microseconds fail before backend execution. + */ + public function test_mysql_date_arithmetic_overlong_composite_interval_microseconds_fail_closed_before_backend_execution(): void { + $queries = array( + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1.1234567' SECOND_MICROSECOND) AS shifted", + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1 02:03:04.1234567' DAY_MICROSECOND) AS shifted", + "SELECT DATE_SUB(post_date_gmt, INTERVAL '1 02:03:04.1234567' DAY_MICROSECOND) AS shifted", + "SELECT TIMESTAMPADD(SECOND_MICROSECOND, '1.1234567', post_date_gmt) AS shifted", + "SELECT TIMESTAMPADD(DAY_MICROSECOND, '1 02:03:04.1234567', post_date_gmt) AS shifted", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected overlong composite interval microseconds to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL date arithmetic statement.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests invalid date arithmetic forms fail before backend execution. + */ + public function test_mysql_date_arithmetic_unsupported_shapes_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT DATE_ADD(post_date_gmt, 1) AS shifted', + 'SELECT DATE_SUB(post_date_gmt, 1) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 fortnight) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 6/4 HOUR_MINUTE) AS shifted', + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1:2:3' MINUTE_SECOND) AS shifted", + 'SELECT TIMESTAMPADD(FORTNIGHT, 1, post_date_gmt) AS shifted', + 'SELECT TIMESTAMPADD(DAY_SECOND, interval_value, post_date_gmt) AS shifted', + 'SELECT TIMESTAMPADD(DAY_SECOND, 6/4, post_date_gmt) AS shifted', + "SELECT TIMESTAMPADD(MINUTE_SECOND, '1:2:3', post_date_gmt) AS shifted", + 'SELECT TIMESTAMPADD(DAY, 1) AS shifted', + 'SELECT TIMESTAMPDIFF(FORTNIGHT, started_at, finished_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY_SECOND, started_at, finished_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY, started_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY, started_at, finished_at, extra_at) AS diff', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported date arithmetic statement to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL date arithmetic statement.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports constant scalar subquery assignments. + */ + public function test_options_upsert_scalar_subquery_assignment_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'yes')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'http://example.net')"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT \'http://example.net\') AS text)', + ) + ); + + $rows = $driver->query( "SELECT option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + + $dual_tail_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'http://example.net/dual' FROM DUAL WHERE 1 = 1 ORDER BY 1 LIMIT 1)"; + + $this->assertSame( 1, $driver->query( $dual_tail_upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT \'http://example.net/dual\' WHERE 1 = 1 ORDER BY 1 LIMIT 1) AS text)', + ) + ); + + $rows = $driver->query( "SELECT option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net/dual', $rows[0]->option_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports table-backed scalar subquery assignments. + */ + public function test_options_upsert_table_backed_scalar_subquery_assignment_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE wptests_upsert_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('source_counts', '0', '0')" ); + $driver->query( "INSERT INTO wptests_upsert_source (id, label) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(*) FROM `wptests_upsert_source` WHERE `id` > 1), + `autoload` = (SELECT `s`.`label` FROM `wptests_upsert_source` AS `s` WHERE `s`.`id` = 2)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT COUNT(*) FROM "wptests_upsert_source" WHERE "id" > 1) AS text), "autoload" = CAST((SELECT "s"."label" FROM "wptests_upsert_source" AS "s" WHERE "s"."id" = 2) AS text)', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->option_value ); + $this->assertSame( 'two', $rows[0]->autoload ); + + $count_literal_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(1) FROM `wptests_upsert_source` WHERE `id` > 0), + `autoload` = (SELECT COUNT(NULL) FROM `wptests_upsert_source`)"; + + $this->assertSame( 1, $driver->query( $count_literal_upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT COUNT(1) FROM "wptests_upsert_source" WHERE "id" > 0) AS text), "autoload" = CAST((SELECT COUNT(NULL) FROM "wptests_upsert_source") AS text)', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->option_value ); + $this->assertSame( '0', $rows[0]->autoload ); + + $ordered_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT `label` FROM `wptests_upsert_source` ORDER BY `id` DESC LIMIT 1), + `autoload` = (SELECT `s`.`label` FROM `wptests_upsert_source` AS `s` WHERE `s`.`id` > 0 ORDER BY `s`.`id` ASC LIMIT 1, 1)"; + + $this->assertSame( 1, $driver->query( $ordered_upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT "label" FROM "wptests_upsert_source" ORDER BY "id" DESC LIMIT 1) AS text), "autoload" = CAST((SELECT "s"."label" FROM "wptests_upsert_source" AS "s" WHERE "s"."id" > 0 ORDER BY "s"."id" ASC LIMIT 1 OFFSET 1) AS text)', + ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'three', $rows[0]->option_value ); + $this->assertSame( 'two', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports scalar subqueries inside expressions. + */ + public function test_options_upsert_scalar_subquery_inside_expression_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_subquery_expr ( + id INTEGER PRIMARY KEY, + counter INTEGER NOT NULL, + bonus INTEGER NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_subquery_expr ( + id int(11) NOT NULL, + counter int(11) NOT NULL DEFAULT 3, + bonus int(11) NOT NULL DEFAULT 2, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_upsert_subquery_expr_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_subquery_expr_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'INSERT INTO wptests_upsert_subquery_expr (id, counter, bonus) VALUES (1, 9, 5)' ); + $driver->query( "INSERT INTO wptests_upsert_subquery_expr_source (id, label) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + + $upsert = 'INSERT INTO `wptests_upsert_subquery_expr` (`id`, `counter`, `bonus`) + VALUES (1, 4, 8) + ON DUPLICATE KEY UPDATE `counter` = (SELECT COUNT(*) FROM `wptests_upsert_subquery_expr_source` WHERE `id` > 1) + VALUES(`counter`) + DEFAULT(`bonus`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_subquery_expr" ("id", "counter", "bonus") VALUES (1, 4, 8) ON CONFLICT ("id") DO UPDATE SET "counter" = (SELECT COUNT(*) FROM "wptests_upsert_subquery_expr_source" WHERE "id" > 1) + excluded."counter" + \'2\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT counter, bonus FROM wptests_upsert_subquery_expr WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '8', $rows[0]->counter ); + $this->assertSame( '5', $rows[0]->bonus ); + } + + /** + * Tests unsupported table-backed scalar subquery shapes fail closed. + */ + public function test_options_upsert_unsupported_table_backed_subquery_assignment_fails_closed(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE wptests_upsert_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_upsert_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('source_counts', '0', '0')" ); + + $queries = array( + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT missing FROM `wptests_upsert_source` WHERE id > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT label FROM `wptests_upsert_source` WHERE missing > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT label FROM `wptests_upsert_source` ORDER BY missing LIMIT 1)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(missing) FROM `wptests_upsert_source`)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'bad' FROM DUAL WHERE missing > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'bad' FROM DUAL ORDER BY missing LIMIT 1)", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported table-backed scalar subquery assignment to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL dbDelta identity CHANGE COLUMN uses catalogs. + */ + public function test_real_pgsql_dbdelta_change_column_identity_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL dbDelta identity catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task917_identity' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $preserve_table = 'task917_identity_' . $suffix . '_preserve'; + $drop_table = 'task917_identity_' . $suffix . '_drop'; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `legacy_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + PRIMARY KEY (`legacy_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $preserve_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE dbDelta identity preserve table', + $backend_sql + ); + + $preserve_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` CHANGE COLUMN `legacy_id` `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', + $preserve_table + ) + ) + ); + $preserve_sql = implode( "\n", array_slice( $logged_sql, $preserve_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CHANGE COLUMN preserves existing identity', + $backend_sql + ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $preserve_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_get_serial_sequence', $preserve_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $preserve_table . '" RENAME COLUMN "legacy_id" TO "id"', $preserve_sql ); + $this->assertStringNotContainsString( 'ADD GENERATED BY DEFAULT AS IDENTITY', $preserve_sql ); + $this->assertStringNotContainsString( 'DROP IDENTITY IF EXISTS', $preserve_sql ); + + $preserve_columns = $driver->query( 'SHOW COLUMNS FROM `' . $preserve_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after identity preserve CHANGE COLUMN', + $backend_sql + ); + $id_column = $this->find_row_by_value( $preserve_columns, 'Field', 'id' ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'Extra' ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`slug`) VALUES ('generated')", $preserve_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'generated insert after identity preserve CHANGE COLUMN', + $backend_sql + ); + $generated_rows = $driver->query( 'SELECT `id`, `slug` FROM `' . $preserve_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read generated identity row after preserve', + $backend_sql + ); + $this->assertCount( 1, $generated_rows ); + $this->assertSame( '1', (string) $this->get_row_value( $generated_rows[0], 'id' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $drop_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE dbDelta identity drop table', + $backend_sql + ); + + $drop_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` CHANGE COLUMN `id` `id` int NOT NULL', $drop_table ) ) + ); + $drop_sql = implode( "\n", array_slice( $logged_sql, $drop_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CHANGE COLUMN drops removed AUTO_INCREMENT identity', + $backend_sql + ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $drop_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_get_serial_sequence', $drop_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $drop_table . '" ALTER COLUMN "id" DROP IDENTITY IF EXISTS', $drop_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $drop_table . '" ALTER COLUMN "id" DROP DEFAULT', $drop_sql ); + + $drop_columns = $driver->query( 'SHOW COLUMNS FROM `' . $drop_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after identity drop CHANGE COLUMN', + $backend_sql + ); + $plain_id_column = $this->find_row_by_value( $drop_columns, 'Field', 'id' ); + $this->assertSame( '', $this->get_row_value( $plain_id_column, 'Extra' ) ); + + $identity_state = $pdo->prepare( + "SELECT is_identity, column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = 'id'" + ); + $identity_state->execute( array( $drop_table ) ); + $identity_row = $identity_state->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 'NO', $identity_row['is_identity'] ); + $this->assertNull( $identity_row['column_default'] ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task917_identity' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task917_identity' ) ); + } + } + + /** + * Tests ALTER TABLE can drop and recreate the same column name in one batch. + */ /** + * Tests ALTER TABLE can rename a column and then re-add the original name in one batch. + */ /** + * Tests ALTER TABLE can drop and recreate the same secondary or primary key name in one batch. + */ /** + * Tests same-name ALTER TABLE replacements still require DROP before ADD. + */ /** + * Tests parenthesized ALTER TABLE ADD column lists ignore MySQL placement. + */ /** + * Tests malformed placement in parenthesized ALTER TABLE ADD lists fails before backend execution. + */ /** + * Tests duplicate columns inside parenthesized ALTER TABLE ADD lists fail atomically. + */ /** + * Tests duplicate ALTER TABLE ADD COLUMN statements fail before backend execution. + */ /** + * Tests same-statement duplicate ALTER TABLE ADD COLUMN batches fail atomically. + */ /** + * Tests duplicate ALTER TABLE ADD INDEX statements fail before backend execution. + */ /** + * Tests same-statement duplicate ALTER TABLE ADD INDEX batches fail atomically. + */ /** + * Tests alias-only CREATE TABLE statements use the MySQL DDL translator. + */ + public function test_create_table_with_only_mysql_type_alias_markers_uses_translator(): void { + $schema = 'CREATE TABLE wptests_alias_create ( + flags BIT(10), + enabled BOOL, + amount DEC(10,2), + fixed_value FIXED(8,3), + real_value REAL + )'; + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wptests_alias_create\" (\n \"flags\" __wp_mysql_bit_10,\n \"enabled\" __wp_mysql_bool,\n \"amount\" __wp_mysql_dec_10_2,\n \"fixed_value\" __wp_mysql_fixed_8_3,\n \"real_value\" __wp_mysql_real\n)", + ), + $translator->translate_schema( $schema ) + ); + + $metadata = $translator->extract_schema_metadata( $schema, true ); + $columns = $metadata[0]['columns']; + $this->assertSame( + array( 'bit(10)', 'bool', 'dec(10,2)', 'fixed(8,3)', 'real' ), + array_column( $columns, 'type' ) + ); + } + + /** + * Tests PostgreSQL trigger DDL for ON UPDATE CURRENT_TIMESTAMP columns. + */ + public function test_on_update_current_timestamp_trigger_statements_use_postgresql_row_trigger(): void { + $driver = $this->create_backendless_driver(); + + $get_create_statements = Closure::bind( + function (): array { + return $this->get_postgresql_on_update_current_timestamp_create_statements( 'public', 'wptests_triggered', 'updated' ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + $get_drop_statements = Closure::bind( + function (): array { + return $this->get_postgresql_on_update_current_timestamp_drop_statements( 'public', 'wptests_triggered', 'updated' ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $create_statements = $get_create_statements(); + $this->assertCount( 3, $create_statements ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION ', $create_statements[0] ); + $this->assertStringContainsString( '__wp_pg_on_update_fn_', $create_statements[0] ); + $this->assertStringContainsString( 'NEW."updated" IS NOT DISTINCT FROM OLD."updated"', $create_statements[0] ); + $this->assertStringContainsString( 'to_jsonb(NEW) - \'updated\' IS DISTINCT FROM to_jsonb(OLD) - \'updated\'', $create_statements[0] ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')", $create_statements[0] ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $create_statements[1] ); + $this->assertStringContainsString( 'CREATE TRIGGER "__wp_pg_on_update_', $create_statements[2] ); + $this->assertStringContainsString( 'BEFORE UPDATE ON "public"."wptests_triggered"', $create_statements[2] ); + + $drop_statements = $get_drop_statements(); + $this->assertCount( 2, $drop_statements ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $drop_statements[0] ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS ', $drop_statements[1] ); + $this->assertStringContainsString( '__wp_pg_on_update_fn_', $drop_statements[1] ); + } + + /** + * Tests catalog projection descriptor arrays keep semicolon literals intact. + */ + public function test_catalog_projection_descriptor_arrays_preserve_semicolon_literals(): void { + $driver = $this->create_backendless_driver(); + $get_sql = Closure::bind( + function ( array $descriptor, ?array &$params ): string { + return $this->get_mysql_catalog_projected_show_sql( $descriptor, $params, false ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $descriptor = array( + 'columns' => array( 'Name', 'Comment' ), + 'expressions' => array( + 'Name' => 't.relname', + 'Comment' => "CASE WHEN true THEN 'a;b=c' ELSE 'x;y' END", + ), + 'from' => 'pg_catalog.pg_class t', + ); + $params = null; + + $sql = $get_sql( $descriptor, $params ); + + $this->assertStringContainsString( "CASE WHEN true THEN 'a;b=c' ELSE 'x;y' END AS \"Comment\"", $sql ); + $this->assertStringContainsString( "'a;b=c'", $sql ); + $this->assertStringContainsString( "'x;y'", $sql ); + $this->assertStringNotContainsString( ' AS "b"', $sql ); + $this->assertStringNotContainsString( ' AS "y"', $sql ); + $this->assertSame( array(), $params ); + } + + /** + * Tests CREATE TABLE routes LONG-prefixed MySQL aliases through the DDL translator. + */ + public function test_create_table_long_aliases_use_sqlite_compatible_metadata(): void { + $schema = 'CREATE TABLE wptests_long_alias_create ( + notes LONG VARCHAR, + raw_data LONG VARBINARY + )'; + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wptests_long_alias_create\" (\n \"notes\" __wp_mysql_mediumtext,\n \"raw_data\" __wp_mysql_mediumblob\n)", + ), + $translator->translate_schema( $schema ) + ); + + $metadata = $translator->extract_schema_metadata( $schema, true ); + $columns = $metadata[0]['columns']; + $this->assertSame( array( 'mediumtext', 'mediumblob' ), array_column( $columns, 'type' ) ); + $this->assertSame( 'utf8mb4', $columns[0]['charset'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns[0]['collation'] ); + $this->assertNull( $columns[1]['charset'] ); + $this->assertNull( $columns[1]['collation'] ); + } + + /** + * Tests ALTER TABLE ADD accepts MySQL data type aliases. + */ + public function test_alter_table_add_accepts_mysql_data_type_aliases(): void { + $driver = $this->create_driver(); + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_dbdelta_alter_table_query', + 'ALTER TABLE wptests_alias_alter + ADD flags BIT(10), + ADD enabled BOOL NOT NULL DEFAULT 0, + ADD toggled BOOLEAN, + ADD amount DEC(10,2), + ADD fixed_value FIXED(8,3), + ADD real_value REAL, + ADD payload JSON DEFAULT NULL, + ADD notes LONG VARCHAR, + ADD raw_data LONG VARBINARY' + ); + + $this->assertNotNull( $translation ); + + $this->assertSame( + array( + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "flags" __wp_mysql_bit_10', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "enabled" __wp_mysql_bool NOT NULL DEFAULT \'0\'', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "toggled" __wp_mysql_boolean', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "amount" __wp_mysql_dec_10_2', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "fixed_value" __wp_mysql_fixed_8_3', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "real_value" __wp_mysql_real', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "payload" __wp_mysql_json DEFAULT NULL', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "notes" __wp_mysql_mediumtext', + 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "raw_data" __wp_mysql_mediumblob', + ), + $translation['statements'] + ); + + $this->assertSame( 'operations', $translation['metadata']['operation'] ); + $this->assertSame( 'public', $translation['metadata']['schema'] ); + $this->assertSame( 'wptests_alias_alter', $translation['metadata']['table'] ); + + $columns = array(); + foreach ( $translation['metadata']['operations'] as $operation ) { + $this->assertSame( 'add_column', $operation['operation'] ); + $columns[] = $operation['column']; + } + $this->assertSame( + array( 'bit(10)', 'bool', 'boolean', 'dec(10,2)', 'fixed(8,3)', 'real', 'json', 'mediumtext', 'mediumblob' ), + array_column( $columns, 'type' ) + ); + $this->assertNull( $columns[6]['charset'] ); + $this->assertNull( $columns[6]['collation'] ); + $this->assertSame( 'utf8mb4', $columns[7]['charset'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns[7]['collation'] ); + $this->assertNull( $columns[8]['charset'] ); + $this->assertNull( $columns[8]['collation'] ); + } + + /** + * Tests ALTER TABLE accepts current database-qualified targets and updates metadata. + */ /** + * Tests ALTER TABLE resolves existing column references case-insensitively. + */ /** + * Tests unsupported CREATE TABLE column attributes fail before backend execution. + */ + public function test_create_table_unsupported_column_attributes_fail_closed_before_backend_execution(): void { + $queries = array( + 'CREATE TABLE wptests_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) STORED)', + 'CREATE TABLE wptests_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) VIRTUAL)', + 'CREATE TABLE wptests_bad_column_attribute (id int INVISIBLE)', + 'CREATE TABLE wptests_bad_column_attribute (id int VISIBLE)', + 'CREATE TABLE wptests_bad_column_attribute (id int COLUMN_FORMAT FIXED)', + 'CREATE TABLE wptests_bad_column_attribute (id int STORAGE DISK)', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE TABLE column attribute to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE column attribute.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests CREATE-time metadata preseed rows exactly match real PostgreSQL catalog rows. + */ + public function test_create_metadata_preseed_rows_match_real_catalog_rows(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_create_metadata_preseed_rows'; + + $this->assertSame( 0, $driver->query( $this->get_create_metadata_preseed_fixture_sql( $table ) ) ); + + $cache_key = $schema . "\0" . $table; + $cache = $this->get_driver_private_property( $driver, 'mysql_column_metadata_introspection_cache' ); + $this->assertArrayHasKey( $cache_key, $cache ); + + $real_rows = $this->read_mysql_catalog_column_metadata_rows( $driver, $schema, $table ); + $this->assertSame( $real_rows, $cache[ $cache_key ] ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( $real_rows, $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ) ); + $this->assertSame( array(), $connection->get_column_catalog_queries() ); + + $rows_by_column = $this->index_metadata_rows_by_column_name( $real_rows ); + $this->assertSame( 'bigint(20) unsigned', $rows_by_column['id']['column_type'] ); + $this->assertSame( 'auto_increment', $rows_by_column['id']['extra'] ); + $this->assertSame( 'varchar(191)', $rows_by_column['title']['column_type'] ); + $this->assertSame( 'big5_chinese_ci', $rows_by_column['title']['collation_name'] ); + $this->assertSame( '', $rows_by_column['title']['column_default'] ); + $this->assertNull( $rows_by_column['body']['column_default'] ); + $this->assertSame( 'CURRENT_TIMESTAMP', $rows_by_column['created_at']['column_default'] ); + $this->assertSame( 'DEFAULT_GENERATED', $rows_by_column['created_at']['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $rows_by_column['touched_at']['extra'] ); + $this->assertSame( 'enum(\'draft\',\'published\')', $rows_by_column['status']['column_type'] ); + $this->assertSame( 'draft', $rows_by_column['status']['column_default'] ); + $this->assertSame( 'set(\'flag-a\',\'flag-b\')', $rows_by_column['flags']['column_type'] ); + $this->assertSame( 'decimal(10,2) unsigned', $rows_by_column['price']['column_type'] ); + $this->assertSame( '0.00', $rows_by_column['price']['column_default'] ); + } + + /** + * Tests factored catalog metadata SQL matches the previous projection shape. + */ + public function test_catalog_metadata_factored_sql_matches_legacy_projection_and_reduces_catalog_calls(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_catalog_metadata_factored_rows'; + + $this->assertSame( 0, $driver->query( $this->get_create_metadata_preseed_fixture_sql( $table ) ) ); + + $legacy_rows = $this->read_legacy_mysql_catalog_column_metadata_rows( $driver, $schema, $table ); + $connection->clear_column_catalog_queries(); + $factored_rows = $this->read_mysql_catalog_column_metadata_rows( $driver, $schema, $table ); + $catalog_queries = $connection->get_column_catalog_queries(); + + $this->assertSame( $legacy_rows, $factored_rows ); + $this->assertCount( 1, $catalog_queries ); + + $catalog_sql = $catalog_queries[0]['sql']; + $this->assertStringContainsString( 'WITH catalog_columns AS MATERIALIZED', $catalog_sql ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $catalog_sql ); + $this->assertStringContainsString( 'FROM catalog_columns c', $catalog_sql ); + $this->assertStringContainsString( 'AS column_comment', $catalog_sql ); + $this->assertStringContainsString( 'AS identity_sequence_comment', $catalog_sql ); + $this->assertStringContainsString( 'c.column_comment', $catalog_sql ); + $this->assertStringContainsString( 'c.identity_sequence_comment', $catalog_sql ); + $this->assertSame( 1, substr_count( $catalog_sql, 'pg_catalog.col_description(pc.oid, pa.attnum)' ) ); + $this->assertSame( 1, substr_count( $catalog_sql, 'pg_catalog.pg_get_serial_sequence(' ) ); + + $rows_by_column = $this->index_metadata_rows_by_column_name( $factored_rows ); + $this->assertSame( 'bigint(20) unsigned', $rows_by_column['id']['column_type'] ); + $this->assertSame( 'auto_increment', $rows_by_column['id']['extra'] ); + $this->assertSame( 'varchar(191)', $rows_by_column['title']['column_type'] ); + $this->assertSame( 'big5_chinese_ci', $rows_by_column['title']['collation_name'] ); + $this->assertSame( '', $rows_by_column['title']['column_default'] ); + $this->assertSame( 'longtext', $rows_by_column['body']['column_type'] ); + $this->assertSame( 'CURRENT_TIMESTAMP', $rows_by_column['created_at']['column_default'] ); + $this->assertSame( 'DEFAULT_GENERATED', $rows_by_column['created_at']['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $rows_by_column['touched_at']['extra'] ); + $this->assertSame( 'enum(\'draft\',\'published\')', $rows_by_column['status']['column_type'] ); + $this->assertSame( 'set(\'flag-a\',\'flag-b\')', $rows_by_column['flags']['column_type'] ); + $this->assertSame( 'decimal(10,2) unsigned', $rows_by_column['price']['column_type'] ); + + $describe_rows = $driver->query( 'DESCRIBE `' . $table . '`' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $this->find_row_by_value( $describe_rows, 'Field', 'id' ), 'Type' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $this->find_row_by_value( $describe_rows, 'Field', 'id' ), 'Extra' ) ); + } + + /** + * Tests CREATE metadata can preseed SHOW CREATE TABLE row shape without a backend read. + */ + public function test_show_create_table_metadata_preseed_shape_matches_create_builder(): void { + $driver = $this->create_backendless_driver(); + $table = 'wptests_show_create_preseed_shape'; + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( + "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID note', + `title` varchar(191) CHARACTER SET big5 NOT NULL DEFAULT '' COMMENT 'Title note', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `rating` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `title_unique` (`title`(12) DESC) COMMENT 'Title key', + CONSTRAINT `rating_positive` CHECK (`rating` >= 0) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Table note'", + true + ); + $this->assertCount( 1, $metadata ); + + $convert_metadata = Closure::bind( + function ( array $metadata ): ?array { + return $this->get_show_create_table_metadata_from_create_metadata( $metadata ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + $build_create = Closure::bind( + function ( string $table, array $metadata ): string { + return $this->get_mysql_create_table_statement_from_metadata( + $table, + $metadata['columns'], + $metadata['indexes'], + $metadata['foreign_keys'], + $metadata['checks'], + $metadata['table']['comment'], + false, + $metadata['table']['collation'] + ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $convert_metadata instanceof Closure || ! $build_create instanceof Closure ) { + throw new RuntimeException( 'Could not bind SHOW CREATE TABLE metadata preseed helpers.' ); + } + + $show_create_metadata = $convert_metadata( $metadata[0] ); + $this->assertIsArray( $show_create_metadata ); + $this->assertSame( array( 'id', 'title', 'created_at', 'rating' ), array_column( $show_create_metadata['columns'], 'column_name' ) ); + $this->assertSame( 'bigint(20) unsigned', $show_create_metadata['columns'][0]['column_type'] ); + $this->assertSame( 'auto_increment', $show_create_metadata['columns'][0]['extra'] ); + $this->assertSame( 'ID note', $show_create_metadata['columns'][0]['column_comment'] ); + $this->assertSame( 'big5_chinese_ci', $show_create_metadata['columns'][1]['collation_name'] ); + $this->assertSame( 'title_unique', $show_create_metadata['indexes'][1]['key_name'] ); + $this->assertSame( 'D', $show_create_metadata['indexes'][1]['collation'] ); + $this->assertSame( '12', $show_create_metadata['indexes'][1]['sub_part'] ); + $this->assertSame( 'rating_positive', $show_create_metadata['checks'][0]['constraint_name'] ); + $this->assertSame( 'big5_chinese_ci', $show_create_metadata['table']['collation'] ); + + $create_sql = $build_create( $table, $show_create_metadata ); + $this->assertStringContainsString( '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT \'ID note\'', $create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `title_unique` (`title`(12) DESC) COMMENT \'Title key\'', $create_sql ); + $this->assertStringContainsString( 'CONSTRAINT `rating_positive` CHECK ("rating" >= 0)', $create_sql ); + $this->assertStringContainsString( "COMMENT='Table note'", $create_sql ); + } + + /** + * Tests SHOW CREATE TABLE preseed serves the first hit without backend queries. + */ + public function test_show_create_table_metadata_preseed_avoids_first_hit_backend_queries(): void { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $table = 'wptests_show_create_preseed_hot_path'; + $metadata_query = "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(191) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `title_prefix` (`title`(12)) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $metadata_query, true ); + + $seed_metadata = Closure::bind( + function ( array $metadata_tables, string $metadata_query ): void { + $this->seed_mysql_show_create_table_metadata_introspection_cache_for_created_tables( + $metadata_tables, + 'public', + $metadata_query + ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $seed_metadata instanceof Closure ) { + throw new RuntimeException( 'Could not bind SHOW CREATE TABLE metadata preseed seeder.' ); + } + + $seed_metadata( $metadata, $metadata_query ); + + $rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + + $this->assertSame( 0, $connection->get_query_count() ); + $this->assertCount( 1, $rows ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table . '`', (string) $this->get_row_value( $rows[0], 'Create Table' ) ); + $this->assertStringContainsString( 'KEY `title_prefix` (`title`(12))', (string) $this->get_row_value( $rows[0], 'Create Table' ) ); + } + + /** + * Tests SHOW CREATE TABLE uses preseeded metadata and matches catalog fallback. + */ + public function test_show_create_table_uses_preseeded_metadata_without_catalog_queries_and_matches_catalog(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_create_preseed_perf'; + + $this->assertSame( 0, $driver->query( $this->get_create_metadata_preseed_fixture_sql( $table ) ) ); + + $connection->clear_queries(); + $preseed_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $preseed_queries = $connection->get_queries(); + + $this->assertCount( 1, $preseed_rows ); + $this->assertSame( array(), $preseed_queries ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $connection->clear_queries(); + $catalog_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $catalog_queries = $connection->get_queries(); + + $this->assertEquals( $catalog_rows, $preseed_rows ); + $this->assertGreaterThanOrEqual( 5, count( $catalog_queries ) ); + + $this->assertSame( 0, $driver->query( "ALTER TABLE `{$table}` ADD COLUMN `after_alter` varchar(20)" ) ); + $connection->clear_queries(); + $altered_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $altered_queries = $connection->get_queries(); + + $this->assertGreaterThanOrEqual( 5, count( $altered_queries ) ); + $this->assertStringContainsString( '`after_alter` varchar(20)', (string) $this->get_row_value( $altered_rows[0], 'Create Table' ) ); + } + + /** + * Tests factored catalog metadata keeps temp schemas and column lookup case rules. + */ + public function test_catalog_metadata_factored_sql_preserves_temp_table_and_column_lookup_case_rules(): void { + list( $driver, , $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_catalog_metadata_case_lookup'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$table}` ( + `id` int(11), + `MiXeDName` varchar(20), + `lower_name` varchar(20) + )" + ) + ); + $this->assertSame( 0, $driver->query( "CREATE TEMPORARY TABLE `{$table}` ( `temp_col` longtext )" ) ); + + $temp_schema = $this->get_temporary_metadata_schema_for_table( $driver, $table ); + $this->assertNotSame( $schema, $temp_schema ); + + $permanent_rows = $this->read_mysql_catalog_column_metadata_rows( $driver, $schema, $table ); + $this->assertSame( + $this->read_legacy_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), + $permanent_rows + ); + $this->assertSame( array( 'id', 'MiXeDName', 'lower_name' ), array_column( $permanent_rows, 'column_name' ) ); + + $temp_rows = $this->read_mysql_catalog_column_metadata_rows( $driver, $temp_schema, $table ); + $this->assertSame( + $this->read_legacy_mysql_catalog_column_metadata_rows( $driver, $temp_schema, $table ), + $temp_rows + ); + $this->assertSame( array( 'temp_col' ), array_column( $temp_rows, 'column_name' ) ); + $this->assertSame( 'longtext', $temp_rows[0]['column_type'] ); + + $case_insensitive_rows = $this->read_mysql_catalog_column_metadata_rows( + $driver, + $schema, + $table, + 'mixedname' + ); + $this->assertSame( + $this->read_legacy_mysql_catalog_column_metadata_rows( $driver, $schema, $table, 'mixedname' ), + $case_insensitive_rows + ); + $this->assertCount( 1, $case_insensitive_rows ); + $this->assertSame( 'MiXeDName', $case_insensitive_rows[0]['column_name'] ); + + $this->assertSame( + array(), + $this->read_mysql_catalog_column_metadata_rows( $driver, $schema, $table, 'mixedname', true ) + ); + + $case_sensitive_rows = $this->read_mysql_catalog_column_metadata_rows( + $driver, + $schema, + $table, + 'MiXeDName', + true + ); + $this->assertCount( 1, $case_sensitive_rows ); + $this->assertSame( 'MiXeDName', $case_sensitive_rows[0]['column_name'] ); + } + + /** + * Tests CREATE-time metadata preseed does not survive explicit clears, DROP/CREATE, or ALTER. + */ + public function test_create_metadata_preseed_invalidates_on_explicit_clear_drop_create_and_alter(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_create_metadata_preseed_invalidate'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(20), + PRIMARY KEY (`id`) + )" + ) + ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'id', 'title' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), 'column_name' ) + ); + $this->assertSame( array(), $connection->get_column_catalog_queries() ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'id', 'title' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), 'column_name' ) + ); + $this->assertCount( 1, $connection->get_column_catalog_queries() ); + + $this->assertSame( 0, $driver->query( "DROP TABLE `{$table}`" ) ); + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( `replacement` varchar(10) )" ) ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'replacement' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), 'column_name' ) + ); + $this->assertSame( array(), $connection->get_column_catalog_queries() ); + + $this->assertSame( 0, $driver->query( "ALTER TABLE `{$table}` ADD COLUMN `after_alter` varchar(10)" ) ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'replacement', 'after_alter' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), 'column_name' ) + ); + $this->assertCount( 1, $connection->get_column_catalog_queries() ); + } + + /** + * Tests explicit DEFAULT NULL falls back because translator metadata cannot disambiguate it. + */ + public function test_create_metadata_preseed_falls_back_for_explicit_default_null(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $columns = array( + 'plain' => '`value` int(11) DEFAULT NULL', + 'comment_gap' => '`value` varchar(20) DEFAULT /*gap*/ NULL', + 'parenthesized_gap' => '`value` varchar(20) DEFAULT ( /*gap*/ NULL )', + ); + + foreach ( $columns as $suffix => $column ) { + $table = 'wptests_create_metadata_preseed_default_null_' . $suffix; + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( {$column} )" ), $suffix ); + + $cache_key = $schema . "\0" . $table; + $cache = $this->get_driver_private_property( $driver, 'mysql_column_metadata_introspection_cache' ); + $this->assertArrayNotHasKey( $cache_key, $cache, $suffix ); + + $connection->clear_column_catalog_queries(); + $rows = $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ); + + $this->assertSame( array( 'value' ), array_column( $rows, 'column_name' ), $suffix ); + $this->assertCount( 1, $connection->get_column_catalog_queries(), $suffix ); + } + } + + /** + * Tests CREATE-time metadata preseed keeps temporary and permanent tables separate. + */ + public function test_create_metadata_preseed_keeps_temporary_and_permanent_tables_separate(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_create_metadata_preseed_shadow'; + + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( `id` int(11) )" ) ); + $this->assertSame( 0, $driver->query( "CREATE TEMPORARY TABLE `{$table}` ( `name` varchar(20) )" ) ); + + $temp_schema = $this->get_temporary_metadata_schema_for_table( $driver, $table ); + $this->assertNotSame( $schema, $temp_schema ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'name' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $temp_schema, $table ), 'column_name' ) + ); + $this->assertSame( array(), $connection->get_column_catalog_queries() ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'id' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $schema, $table ), 'column_name' ) + ); + $this->assertCount( 1, $connection->get_column_catalog_queries() ); + + $connection->clear_column_catalog_queries(); + $this->assertSame( + array( 'name' ), + array_column( $this->get_cached_mysql_catalog_column_metadata_rows( $driver, $temp_schema, $table ), 'column_name' ) + ); + $this->assertSame( array(), $connection->get_column_catalog_queries() ); + } + + /** + * Tests SHOW CREATE TABLE preseed includes foreign keys and matches the catalog fallback. + */ + public function test_real_pgsql_show_create_table_preseed_includes_foreign_keys_and_matches_catalog(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $parent_table = 'wptests_show_create_preseed_fk_parent'; + $child_table = 'wptests_show_create_preseed_fk_child'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$parent_table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) + )" + ) + ); + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$child_table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `parent_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `parent_idx` (`parent_id`), + CONSTRAINT `show_create_preseed_fk` FOREIGN KEY (`parent_id`) REFERENCES `{$parent_table}` (`id`) ON DELETE CASCADE + ) COMMENT='FK child'" + ) + ); + + $connection->clear_queries(); + $preseed_rows = $driver->query( 'SHOW CREATE TABLE `' . $child_table . '`' ); + $preseed_queries = $connection->get_queries(); + + $this->assertSame( array(), $preseed_queries ); + $this->assertCount( 1, $preseed_rows ); + $preseed_sql = (string) $this->get_row_value( $preseed_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CONSTRAINT `show_create_preseed_fk` FOREIGN KEY (`parent_id`)', $preseed_sql ); + $this->assertStringContainsString( 'REFERENCES `' . $parent_table . '` (`id`)', $preseed_sql ); + $this->assertStringContainsString( 'ON DELETE CASCADE', $preseed_sql ); + $this->assertStringContainsString( "COMMENT='FK child'", $preseed_sql ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $connection->clear_queries(); + $catalog_rows = $driver->query( 'SHOW CREATE TABLE `' . $child_table . '`' ); + $catalog_queries = $connection->get_queries(); + + $this->assertEquals( $catalog_rows, $preseed_rows ); + $this->assertGreaterThanOrEqual( 5, count( $catalog_queries ) ); + } + + /** + * Tests CREATE TABLE LIKE uses catalog metadata but does not copy foreign keys. + */ + public function test_real_pgsql_create_table_like_does_not_copy_foreign_keys_from_source_table(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $parent_table = 'wptests_create_like_fk_parent'; + $child_table = 'wptests_create_like_fk_child'; + $like_table = 'wptests_create_like_fk_copy'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$parent_table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) + )" + ) + ); + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$child_table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `parent_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `parent_idx` (`parent_id`), + CONSTRAINT `create_like_source_fk` FOREIGN KEY (`parent_id`) REFERENCES `{$parent_table}` (`id`) ON DELETE CASCADE + )" + ) + ); + + $source_rows = $driver->query( 'SHOW CREATE TABLE `' . $child_table . '`' ); + $source_sql = (string) $this->get_row_value( $source_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CONSTRAINT `create_like_source_fk` FOREIGN KEY', $source_sql ); + + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$like_table}` LIKE `{$child_table}`" ) ); + + $connection->clear_queries(); + $like_rows = $driver->query( 'SHOW CREATE TABLE `' . $like_table . '`' ); + $like_queries = $connection->get_queries(); + + $this->assertSame( array(), $like_queries ); + $this->assertCount( 1, $like_rows ); + $like_sql = (string) $this->get_row_value( $like_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $like_table . '`', $like_sql ); + $this->assertStringContainsString( '`parent_id` bigint(20) unsigned NOT NULL', $like_sql ); + $this->assertStringContainsString( 'KEY `parent_idx` (`parent_id`)', $like_sql ); + $this->assertStringNotContainsString( 'FOREIGN KEY', $like_sql ); + $this->assertStringNotContainsString( 'REFERENCES', $like_sql ); + + $like_table_literal = $driver->get_connection()->quote( $like_table ); + $like_fk_rows = $driver->query( + "SELECT constraint_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = {$like_table_literal} + AND referenced_table_name IS NOT NULL" + ); + $this->assertSame( array(), $like_fk_rows ); + } + + /** + * Tests SHOW COLUMNS and SHOW INDEX consume CREATE-time metadata without catalog reads. + */ + public function test_real_pgsql_show_columns_and_index_use_create_metadata_preseed(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_metadata_preseed_columns_index'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(64) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT '' COMMENT 'Title note', + `body` text CHARACTER SET big5, + `status` enum('draft','published') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `title_lookup` (`title`) COMMENT 'Title lookup note', + KEY `body_prefix` (`body`(12)) COMMENT 'Body prefix note' + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='metadata preseed table'" + ) + ); + + $connection->clear_queries(); + $preseed_columns = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table . '`', PDO::FETCH_ASSOC ); + $this->assertSame( array(), $connection->get_queries() ); + $this->assertCount( 4, $preseed_columns ); + + $id_column = $this->find_row_by_value( $preseed_columns, 'Field', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_column, 'Type' ) ); + $this->assertSame( 'NO', $this->get_row_value( $id_column, 'Null' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_column, 'Key' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'Extra' ) ); + + $title_column = $this->find_row_by_value( $preseed_columns, 'Field', 'title' ); + $this->assertSame( 'varchar(64)', $this->get_row_value( $title_column, 'Type' ) ); + $this->assertSame( 'latin1_swedish_ci', $this->get_row_value( $title_column, 'Collation' ) ); + $this->assertSame( 'UNI', $this->get_row_value( $title_column, 'Key' ) ); + $this->assertSame( '', $this->get_row_value( $title_column, 'Default' ) ); + $this->assertSame( 'select,insert,update,references', $this->get_row_value( $title_column, 'Privileges' ) ); + $this->assertSame( 'Title note', $this->get_row_value( $title_column, 'Comment' ) ); + + $body_column = $this->find_row_by_value( $preseed_columns, 'Field', 'body' ); + $this->assertSame( 'MUL', $this->get_row_value( $body_column, 'Key' ) ); + $this->assertSame( 'big5_chinese_ci', $this->get_row_value( $body_column, 'Collation' ) ); + + $connection->clear_queries(); + $filtered_columns = $driver->query( 'SHOW COLUMNS FROM `' . $table . "` LIKE 'tit%'", PDO::FETCH_ASSOC ); + $this->assertSame( array(), $connection->get_queries() ); + $this->assertSame( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), array_keys( $filtered_columns[0] ) ); + $this->assertCount( 1, $filtered_columns ); + $this->assertSame( 'title', $this->get_row_value( $filtered_columns[0], 'Field' ) ); + + $connection->clear_queries(); + $preseed_indexes = $driver->query( 'SHOW INDEX FROM `' . $table . "` WHERE Key_name = 'body_prefix'", PDO::FETCH_ASSOC ); + $this->assertSame( array(), $connection->get_queries() ); + $this->assertCount( 1, $preseed_indexes ); + $body_index = $preseed_indexes[0]; + $this->assertSame( $table, $this->get_row_value( $body_index, 'Table' ) ); + $this->assertSame( '1', $this->get_row_value( $body_index, 'Non_unique' ) ); + $this->assertSame( 'body_prefix', $this->get_row_value( $body_index, 'Key_name' ) ); + $this->assertSame( 'body', $this->get_row_value( $body_index, 'Column_name' ) ); + $this->assertSame( '12', $this->get_row_value( $body_index, 'Sub_part' ) ); + $this->assertSame( '', $this->get_row_value( $body_index, 'Null' ) ); + $this->assertSame( 'Body prefix note', $this->get_row_value( $body_index, 'Index_comment' ) ); + + $connection->clear_queries(); + $unique_indexes = $driver->query( 'SHOW INDEX FROM `' . $table . '` WHERE Non_unique = 0', PDO::FETCH_ASSOC ); + $this->assertSame( array(), $connection->get_queries() ); + $this->assertCount( 2, $unique_indexes ); + $this->assertSame( array( 'PRIMARY', 'title_lookup' ), array_column( $unique_indexes, 'Key_name' ) ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + + $connection->clear_queries(); + $catalog_columns = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table . '`', PDO::FETCH_ASSOC ); + $this->assertGreaterThanOrEqual( 1, count( $connection->get_queries() ) ); + $this->assertEquals( $catalog_columns, $preseed_columns ); + + $connection->clear_queries(); + $catalog_indexes = $driver->query( 'SHOW INDEX FROM `' . $table . "` WHERE Key_name = 'body_prefix'", PDO::FETCH_ASSOC ); + $this->assertGreaterThanOrEqual( 1, count( $connection->get_queries() ) ); + $this->assertEquals( $catalog_indexes, $preseed_indexes ); + + $connection->clear_queries(); + $catalog_tables = $driver->query( 'SHOW FULL TABLES LIKE ' . $driver->get_connection()->quote( $table ), PDO::FETCH_ASSOC ); + $this->assertGreaterThanOrEqual( 1, count( $connection->get_queries() ) ); + $this->assertCount( 1, $catalog_tables ); + + $connection->clear_queries(); + $cached_tables = $driver->query( 'SHOW FULL TABLES LIKE ' . $driver->get_connection()->quote( $table ), PDO::FETCH_ASSOC ); + $this->assertSame( array(), $connection->get_queries() ); + $this->assertEquals( $catalog_tables, $cached_tables ); + } + + /** + * Tests SHOW CREATE TABLE preseed keeps temporary table shadowing separate. + */ + public function test_real_pgsql_show_create_table_preseed_uses_temporary_shadow_table(): void { + list( $driver ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_create_preseed_temp_shadow'; + + $this->assertSame( 0, $driver->query( "CREATE TEMPORARY TABLE `{$table}` ( `name` varchar(20), KEY `name_key` (`name`) ) COMMENT='temporary table'" ) ); + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( `id` int(11), KEY `id_key` (`id`) ) COMMENT='permanent table'" ) ); + + $temp_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertCount( 1, $temp_rows ); + $temp_sql = (string) $this->get_row_value( $temp_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE `' . $table . '`', $temp_sql ); + $this->assertStringContainsString( '`name` varchar(20)', $temp_sql ); + $this->assertStringContainsString( 'KEY `name_key` (`name`)', $temp_sql ); + $this->assertStringNotContainsString( '`id` int(11)', $temp_sql ); + $this->assertStringNotContainsString( 'permanent table', $temp_sql ); + + $temp_columns = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->assertSame( 'varchar(20)', $this->get_row_value( $this->find_row_by_value( $temp_columns, 'Field', 'name' ), 'Type' ) ); + $this->assertNull( $this->find_optional_row_by_value( $temp_columns, 'Field', 'id' ) ); + + $this->assertSame( 0, $driver->query( 'DROP TABLE `' . $table . '`' ) ); + + $permanent_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertCount( 1, $permanent_rows ); + $permanent_sql = (string) $this->get_row_value( $permanent_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table . '`', $permanent_sql ); + $this->assertStringNotContainsString( 'CREATE TEMPORARY TABLE', $permanent_sql ); + $this->assertStringContainsString( '`id` int(11)', $permanent_sql ); + $this->assertStringContainsString( 'KEY `id_key` (`id`)', $permanent_sql ); + $this->assertStringContainsString( "COMMENT='permanent table'", $permanent_sql ); + $this->assertStringNotContainsString( '`name` varchar(20)', $permanent_sql ); + + $permanent_columns = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->assertSame( 'int(11)', $this->get_row_value( $this->find_row_by_value( $permanent_columns, 'Field', 'id' ), 'Type' ) ); + $this->assertNull( $this->find_optional_row_by_value( $permanent_columns, 'Field', 'name' ) ); + } + + /** + * Tests SHOW CREATE TABLE preseed invalidates across DROP and replacement CREATE. + */ + public function test_real_pgsql_show_create_table_preseed_invalidates_after_drop_and_recreate(): void { + list( $driver ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_create_preseed_recreate'; + + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( `id` int(11), KEY `id_key` (`id`) ) COMMENT='first table'" ) ); + + $first_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertCount( 1, $first_rows ); + $first_sql = (string) $this->get_row_value( $first_rows[0], 'Create Table' ); + $this->assertStringContainsString( '`id` int(11)', $first_sql ); + $this->assertStringContainsString( 'KEY `id_key` (`id`)', $first_sql ); + $this->assertStringContainsString( "COMMENT='first table'", $first_sql ); + + $this->assertSame( 0, $driver->query( 'DROP TABLE `' . $table . '`' ) ); + $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ) ); + + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( `replacement` varchar(12), UNIQUE KEY `replacement_unique` (`replacement`) ) COMMENT='replacement table'" ) ); + + $replacement_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertCount( 1, $replacement_rows ); + $replacement_sql = (string) $this->get_row_value( $replacement_rows[0], 'Create Table' ); + $this->assertStringContainsString( '`replacement` varchar(12)', $replacement_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `replacement_unique` (`replacement`)', $replacement_sql ); + $this->assertStringContainsString( "COMMENT='replacement table'", $replacement_sql ); + $this->assertStringNotContainsString( '`id` int(11)', $replacement_sql ); + $this->assertStringNotContainsString( 'KEY `id_key`', $replacement_sql ); + $this->assertStringNotContainsString( 'first table', $replacement_sql ); + } + + /** + * Tests SHOW CREATE TABLE preseed supports schema-qualified CREATE targets. + */ + public function test_real_pgsql_show_create_table_preseed_supports_schema_qualified_created_table(): void { + list( $driver, $connection, $schema ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_create_preseed_schema_qualified'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$schema}`.`{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`) + ) COMMENT='schema qualified table'" + ) + ); + + $connection->clear_queries(); + $preseed_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema . '`.`' . $table . '`' ); + $preseed_queries = $connection->get_queries(); + + $this->assertSame( array(), $preseed_queries ); + $this->assertCount( 1, $preseed_rows ); + $preseed_sql = (string) $this->get_row_value( $preseed_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table . '`', $preseed_sql ); + $this->assertStringContainsString( '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $preseed_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $preseed_sql ); + $this->assertStringContainsString( "COMMENT='schema qualified table'", $preseed_sql ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $catalog_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema . '`.`' . $table . '`' ); + $this->assertEquals( $catalog_rows, $preseed_rows ); + } + + /** + * Tests SHOW CREATE TABLE renders column collations and still matches catalog fallback. + */ + public function test_real_pgsql_show_create_table_preseed_renders_column_collations_and_matches_catalog(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $table = 'wptests_show_create_preseed_column_collations'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(64) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT '', + `body` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, + PRIMARY KEY (`id`) + ) DEFAULT CHARACTER SET big5 COLLATE big5_chinese_ci COMMENT='column collation table'" + ) + ); + + $connection->clear_queries(); + $preseed_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $preseed_queries = $connection->get_queries(); + + $this->assertSame( array(), $preseed_queries ); + $this->assertCount( 1, $preseed_rows ); + $preseed_sql = (string) $this->get_row_value( $preseed_rows[0], 'Create Table' ); + $this->assertStringContainsString( "`title` varchar(64) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT ''", $preseed_sql ); + $this->assertStringContainsString( '`body` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL', $preseed_sql ); + $this->assertStringContainsString( 'DEFAULT CHARSET=big5 COLLATE=big5_chinese_ci', $preseed_sql ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $catalog_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertEquals( $catalog_rows, $preseed_rows ); + } + + /** + * Tests explicit DEFAULT NULL skips SHOW CREATE TABLE preseed and uses catalog metadata. + */ + public function test_real_pgsql_show_create_table_preseed_skips_explicit_default_null(): void { + list( $driver, $connection ) = $this->create_create_metadata_preseed_capture_driver(); + $columns = array( + 'plain' => '`value` int(11) DEFAULT NULL', + 'comment_gap' => '`value` varchar(20) DEFAULT /*gap*/ NULL', + 'parenthesized_gap' => '`value` varchar(20) DEFAULT ( /*gap*/ NULL )', + ); + + foreach ( $columns as $suffix => $column ) { + $table = 'wptests_show_create_preseed_default_null_' . $suffix; + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$table}` ( {$column} )" ), $suffix ); + + $connection->clear_queries(); + $first_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $first_queries = $connection->get_queries(); + + $this->assertGreaterThanOrEqual( 5, count( $first_queries ), $suffix ); + $this->assertCount( 1, $first_rows, $suffix ); + $this->assertStringContainsString( '`value` ', (string) $this->get_row_value( $first_rows[0], 'Create Table' ), $suffix ); + $this->assertStringContainsString( 'DEFAULT NULL', (string) $this->get_row_value( $first_rows[0], 'Create Table' ), $suffix ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $catalog_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->assertEquals( $catalog_rows, $first_rows, $suffix ); + } + } + + /** + * Tests unsupported ALTER TABLE column attributes fail before backend execution. + */ + public function test_alter_table_unsupported_column_attributes_fail_closed_before_backend_execution(): void { + $queries = array( + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN generated_value int GENERATED ALWAYS AS (base + 1) STORED', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN hidden_value int INVISIBLE', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN stored_value int COLUMN_FORMAT FIXED', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN disk_value int STORAGE DISK', + 'ALTER TABLE wptests_bad_column_attribute MODIFY COLUMN hidden_value int INVISIBLE', + 'ALTER TABLE wptests_bad_column_attribute CHANGE COLUMN hidden_value hidden_value int INVISIBLE', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE column attribute to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests ALTER TABLE rejects non-current schema-qualified targets before backend execution. + */ + public function test_alter_table_rejects_non_current_schema_qualified_targets(): void { + $queries = array( + 'ALTER TABLE other_db.t ADD COLUMN name VARCHAR(255)' => 'Unsupported ALTER TABLE statement.', + 'ALTER TABLE information_schema.tables ADD COLUMN name VARCHAR(255)' => 'Unsupported information_schema query.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver( 'wp' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + $driver = $this->create_driver( 'wp' ); + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'ALTER TABLE tables ADD COLUMN name VARCHAR(255)' ); + $this->fail( 'Expected information_schema ALTER TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ALTER TABLE RENAME TO accepts current database and public-qualified targets. + */ /** + * Tests ALTER TABLE RENAME AS and bare RENAME forms are accepted. + */ /** + * Tests ALTER COLUMN DROP DEFAULT updates backend and MySQL metadata. + */ /** + * Tests real PostgreSQL ALTER COLUMN default metadata uses catalogs. + */ + public function test_real_pgsql_alter_column_default_metadata_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL ALTER COLUMN DEFAULT catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task917_default' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task917_default_' . $suffix; + $table_literal = $driver->get_connection()->quote( $table ); + $database_literal = $driver->get_connection()->quote( $database_name ); + $metadata_comment = "__wp_mysql_column_default:REFURV9BRERfREFZKDEp\n__wp_mysql_column_type:dmFyY2hhcigyMCk=\nStatus note"; + $quote_identifier = static function ( string $identifier ): string { + return WP_PostgreSQL_Connection::quote_identifier_value( $identifier ); + }; + $set_status_comment = function () use ( $pdo, $metadata_comment, $quote_identifier, $table ): void { + $pdo->exec( + 'COMMENT ON COLUMN ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $table ) + . '.' + . $quote_identifier( 'status' ) + . ' IS ' + . $pdo->quote( $metadata_comment ) + ); + }; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `status` varchar(20) DEFAULT 'draft' COMMENT 'Status note', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE ALTER COLUMN DEFAULT fixture table', + $backend_sql + ); + + $set_status_comment(); + $this->assertSame( + 0, + $driver->query( "ALTER TABLE `{$table}` ALTER COLUMN `status` SET DEFAULT 'published'" ) + ); + $set_default_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER COLUMN SET DEFAULT', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" ALTER COLUMN "status" SET DEFAULT \'published\'', $set_default_sql ); + $this->assertStringContainsString( 'pg_catalog.col_description(c.oid, a.attnum)', $set_default_sql ); + $this->assertStringContainsString( 'COMMENT ON COLUMN "public"."' . $table . '"."status" IS ', $set_default_sql ); + $this->assertStringNotContainsString( '__wp_mysql_column_default:', $set_default_sql ); + $this->assertStringContainsString( '__wp_mysql_column_type:dmFyY2hhcigyMCk=', $set_default_sql ); + $this->assertStringContainsString( 'Status note', $set_default_sql ); + + $columns_after_set = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after SET DEFAULT', + $backend_sql + ); + $status_after_set = $this->find_row_by_value( $columns_after_set, 'Field', 'status' ); + $this->assertSame( 'published', $this->get_row_value( $status_after_set, 'Default' ) ); + + $set_column_rows = $driver->query( + "SELECT column_name, column_default, column_comment + FROM information_schema.columns + WHERE table_schema = {$database_literal} + AND table_name = {$table_literal} + AND column_name = 'status'" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns after SET DEFAULT', + $backend_sql + ); + $this->assertCount( 1, $set_column_rows ); + $this->assertSame( 'published', $this->get_row_value( $set_column_rows[0], 'COLUMN_DEFAULT' ) ); + $this->assertSame( 'Status note', $this->get_row_value( $set_column_rows[0], 'COLUMN_COMMENT' ) ); + + $set_status_comment(); + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE `' . $table . '` ALTER COLUMN `status` DROP DEFAULT' ) + ); + $drop_default_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER COLUMN DROP DEFAULT', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" ALTER COLUMN "status" DROP DEFAULT', $drop_default_sql ); + $this->assertStringContainsString( 'pg_catalog.col_description(c.oid, a.attnum)', $drop_default_sql ); + $this->assertStringContainsString( 'COMMENT ON COLUMN "public"."' . $table . '"."status" IS ', $drop_default_sql ); + $this->assertStringNotContainsString( '__wp_mysql_column_default:', $drop_default_sql ); + $this->assertStringContainsString( '__wp_mysql_column_type:dmFyY2hhcigyMCk=', $drop_default_sql ); + + $columns_after_drop = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after DROP DEFAULT', + $backend_sql + ); + $status_after_drop = $this->find_row_by_value( $columns_after_drop, 'Field', 'status' ); + $this->assertNull( $this->get_row_value( $status_after_drop, 'Default' ) ); + + $drop_column_rows = $driver->query( + "SELECT column_name, column_default, column_comment + FROM information_schema.columns + WHERE table_schema = {$database_literal} + AND table_name = {$table_literal} + AND column_name = 'status'" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns after DROP DEFAULT', + $backend_sql + ); + $this->assertCount( 1, $drop_column_rows ); + $this->assertNull( $this->get_row_value( $drop_column_rows[0], 'COLUMN_DEFAULT' ) ); + $this->assertSame( 'Status note', $this->get_row_value( $drop_column_rows[0], 'COLUMN_COMMENT' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task917_default' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task917_default' ) ); + } + } + + /** + * Tests MySQL table-option ALTER clauses are supported no-ops. + */ /** + * Tests MySQL table-option ALTER clauses accept optional-equals forms as no-ops. + */ /** + * Tests unsupported ALTER TABLE comment option values fail before backend execution. + */ + public function test_alter_table_comment_option_requires_string_literal(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_alter_bad_comment (id INTEGER)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_alter_bad_comment ( + id int(11) DEFAULT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_bad_comment COMMENT = 123' ); + $this->fail( 'Expected unsupported ALTER TABLE comment option to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL ALTER TABLE ORDER BY clauses are supported schema no-ops. + */ /** + * Tests malformed ALTER TABLE ORDER BY clauses fail before backend execution. + */ + public function test_alter_table_order_by_clauses_fail_closed_for_unsupported_forms(): void { + $queries = array( + 'ALTER TABLE wptests_bad_order_by ORDER BY', + 'ALTER TABLE wptests_bad_order_by ORDER BY id,', + 'ALTER TABLE wptests_bad_order_by ORDER BY other_table.id', + 'ALTER TABLE wptests_bad_order_by ORDER BY RAND()', + 'ALTER TABLE wptests_bad_order_by ORDER BY id, ADD COLUMN note varchar(20)', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_bad_order_by (id INTEGER, status TEXT)' ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_bad_order_by ( + id int(11) DEFAULT NULL, + status varchar(20) DEFAULT NULL + )' + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE ORDER BY clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ALTER TABLE AUTO_INCREMENT adjusts the PostgreSQL identity sequence. + */ + public function test_alter_table_auto_increment_updates_postgresql_identity_sequence(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_alter_auto_increment ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment AUTO_INCREMENT = 50' ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_alter_auto_increment (name) VALUES ('first')" ) ); + + $rows = $driver->query( 'SELECT id, name FROM wptests_alter_auto_increment' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '50', $rows[0]->id ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment AUTO_INCREMENT = 1' ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_alter_auto_increment (name) VALUES ('second')" ) ); + + $rows = $driver->query( 'SELECT id, name FROM wptests_alter_auto_increment ORDER BY id' ); + $this->assertSame( '50', $rows[0]->id ); + $this->assertSame( '51', $rows[1]->id ); + + $driver->query( + 'CREATE TABLE wptests_alter_auto_increment_plain ( + id int(11) DEFAULT NULL, + name varchar(191) DEFAULT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment_plain AUTO_INCREMENT = 500' ) ); + } + + /** + * Tests unsupported SHOW TABLES database qualifiers fail before backend execution. + */ + public function test_show_tables_unsupported_database_qualification_does_not_reach_backend(): void { + $queries = array( + 'SHOW TABLES FROM other_db', + 'SHOW TABLES FROM pg_catalog', + 'SHOW TABLES FROM other_db LIKE 1', + 'SHOW TABLES FROM other_db EXTRA', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_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 real PostgreSQL catalog roundtrips use PostgreSQL catalog metadata. + */ + public function test_real_pgsql_catalog_roundtrip_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL catalog roundtrip test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_views_with_prefix( $pdo, 'task813' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task813' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $parent_table = 'task813_' . $suffix . '_parent'; + $child_table = 'task813_' . $suffix . '_child'; + $view_table = 'task813_' . $suffix . '_view'; + $escaped_table = 'task813_' . $suffix . '_wp_e2e_users'; + $escaped_shadows = array( + 'task813_' . $suffix . 'x_wp_e2e_users', + 'task813_' . $suffix . '_wpx_e2e_users', + 'task813_' . $suffix . '_wp_e2eXusers', + ); + $foreign_key = $child_table . '_parent_fk'; + $check_constraint = $child_table . '_status_check'; + $child_literal = $driver->get_connection()->quote( $child_table ); + $parent_literal = $driver->get_connection()->quote( $parent_table ); + $fk_literal = $driver->get_connection()->quote( $foreign_key ); + $check_literal = $driver->get_connection()->quote( $check_constraint ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `title` varchar(191) NOT NULL COMMENT 'Parent title note', + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `title_idx` (`title`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 813 parent table'", + $parent_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE parent table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `parent_id` bigint(20) unsigned NOT NULL, + `slug` varchar(64) NOT NULL, + `description` varchar(255) NOT NULL COMMENT 'Child description note', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `status` varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `parent_idx` (`parent_id`), + KEY `description_prefix` (`description`(191)), + CONSTRAINT `%s` FOREIGN KEY (`parent_id`) REFERENCES `%s` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `%s` CHECK (`status` <> 'blocked') + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 813 child table'", + $child_table, + $foreign_key, + $parent_table, + $check_constraint + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE child table', + $backend_sql + ); + + foreach ( array_merge( array( $escaped_table ), $escaped_shadows ) as $show_tables_name ) { + $pdo->exec( + 'CREATE TABLE public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $show_tables_name ) + . ' (id integer)' + ); + } + $pdo->exec( + 'CREATE VIEW public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $view_table ) + . ' AS SELECT id, slug FROM public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) + ); + + $show_tables = $driver->query( 'SHOW TABLES LIKE ' . $child_literal ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLES LIKE', + $backend_sql + ); + $this->assertCount( 1, $show_tables ); + $this->assertContains( $child_table, array_values( get_object_vars( $show_tables[0] ) ) ); + + $escaped_like_pattern = 'task813\\_' . $suffix . '\\_wp\\_e2e\\_users'; + $escaped_show_tables = $driver->query( "SHOW TABLES LIKE 'task813\\\\_{$suffix}\\\\_wp\\\\_e2e\\\\_users'" ); + $escaped_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLES LIKE escaped underscores', + $backend_sql + ); + $this->assertCount( 1, $escaped_show_tables ); + $this->assertSame( 'Tables_in_' . $database_name, $driver->get_last_column_meta()[0]['name'] ); + $this->assertContains( $escaped_table, array_values( get_object_vars( $escaped_show_tables[0] ) ) ); + $this->assertCount( 1, $escaped_queries ); + $escape_sql = $connection->quote( '\\' ); + $this->assertStringContainsString( 'LIKE ? ESCAPE ' . $escape_sql, $escaped_queries[0]['sql'] ); + $this->assertStringNotContainsString( "ESCAPE '\\'", $escaped_queries[0]['sql'] ); + $this->assertSame( array( $database_name, $escaped_like_pattern ), $escaped_queries[0]['params'] ); + + $table_column = 'Tables_in_' . $database_name; + $task_show_tables_pattern = 'task813\\_' . $suffix . '\\_%'; + $task_show_tables_literal = "'task813\\\\_{$suffix}\\\\_%'"; + $full_tables = $driver->query( 'SHOW FULL TABLES LIKE ' . $task_show_tables_literal ); + $full_tables_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL TABLES LIKE task prefix', + $backend_sql + ); + $this->assertCount( 6, $full_tables ); + $this->assertSame( array( $table_column, 'Table_type' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $full_table_types = array(); + foreach ( $full_tables as $full_table ) { + $full_table_types[ (string) $this->get_row_value( $full_table, $table_column ) ] = (string) $this->get_row_value( $full_table, 'Table_type' ); + } + $this->assertSame( 'BASE TABLE', $full_table_types[ $parent_table ] ?? null ); + $this->assertSame( 'BASE TABLE', $full_table_types[ $child_table ] ?? null ); + $this->assertSame( 'VIEW', $full_table_types[ $view_table ] ?? null ); + $this->assertCount( 1, $full_tables_queries ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $full_tables_queries[0]['sql'] ); + $this->assertStringContainsString( 'AS "' . $table_column . '"', $full_tables_queries[0]['sql'] ); + $this->assertStringContainsString( 'AS "Table_type"', $full_tables_queries[0]['sql'] ); + $this->assertStringContainsString( 'LIKE ? ESCAPE ' . $escape_sql, $full_tables_queries[0]['sql'] ); + $this->assertSame( array( $database_name, $task_show_tables_pattern ), $full_tables_queries[0]['params'] ); + + $current_database_tables = $driver->query( 'SHOW TABLES FROM `' . $database_name . '`' ); + $current_database_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLES FROM current database', + $backend_sql + ); + $this->assertSame( array( $table_column ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertNotNull( $this->find_row_by_value( $current_database_tables, $table_column, $parent_table ) ); + $this->assertNotNull( $this->find_row_by_value( $current_database_tables, $table_column, $child_table ) ); + $this->assertNotNull( $this->find_row_by_value( $current_database_tables, $table_column, $view_table ) ); + $this->assertCount( 1, $current_database_queries ); + $this->assertStringNotContainsString( 'SHOW TABLES', $current_database_queries[0]['sql'] ); + $this->assertSame( array( $database_name ), $current_database_queries[0]['params'] ); + + $current_database_like_tables = $driver->query( 'SHOW TABLES IN `' . $database_name . '` LIKE ' . $task_show_tables_literal ); + $current_database_like_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLES IN current database LIKE task prefix', + $backend_sql + ); + $this->assertCount( 6, $current_database_like_tables ); + $this->assertSame( array( $table_column ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $this->find_row_by_value( $current_database_like_tables, $table_column, $parent_table ), $table_column ) ); + $this->assertCount( 1, $current_database_like_queries ); + $this->assertStringNotContainsString( 'SHOW TABLES', $current_database_like_queries[0]['sql'] ); + $this->assertSame( array( $database_name, $task_show_tables_pattern ), $current_database_like_queries[0]['params'] ); + + $current_database_full_tables = $driver->query( 'SHOW FULL TABLES FROM `' . $database_name . '` LIKE ' . $task_show_tables_literal ); + $current_database_full_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL TABLES FROM current database LIKE task prefix', + $backend_sql + ); + $this->assertCount( 6, $current_database_full_tables ); + $this->assertSame( array( $table_column, 'Table_type' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $current_full_table_types = array(); + foreach ( $current_database_full_tables as $current_database_full_table ) { + $current_full_table_types[ (string) $this->get_row_value( $current_database_full_table, $table_column ) ] = (string) $this->get_row_value( $current_database_full_table, 'Table_type' ); + } + $this->assertSame( 'BASE TABLE', $current_full_table_types[ $child_table ] ?? null ); + $this->assertSame( 'VIEW', $current_full_table_types[ $view_table ] ?? null ); + $this->assertSame( array(), $current_database_full_queries ); + $this->assertEquals( $full_tables, $current_database_full_tables ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + $create_rows = $driver->query( 'SHOW CREATE TABLE `' . $child_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE', + $backend_sql + ); + $show_create_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertCount( 1, $create_rows ); + $create_table_sql = (string) $this->get_row_value( $create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $child_table . '`', $create_table_sql ); + $this->assertStringContainsString( '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $create_table_sql ); + $this->assertStringContainsString( '`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP', $create_table_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $create_table_sql ); + $this->assertStringContainsString( 'KEY `parent_idx` (`parent_id`)', $create_table_sql ); + $this->assertStringContainsString( 'KEY `description_prefix` (`description`(191))', $create_table_sql ); + $this->assertStringContainsString( 'CONSTRAINT `' . $foreign_key . '` FOREIGN KEY', $create_table_sql ); + $this->assertStringContainsString( 'REFERENCES `' . $parent_table . '` (`id`)', $create_table_sql ); + $this->assertStringContainsString( 'ON DELETE CASCADE', $create_table_sql ); + $this->assertStringContainsString( 'CONSTRAINT `' . $check_constraint . '` CHECK', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 813 child table\'', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT \'Child description note\'', $create_table_sql ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $show_create_sql ); + $this->assertStringContainsString( 'pg_catalog.col_description(pc.oid, pa.attnum)', $show_create_sql ); + $this->assertStringContainsString( 'SUBSTRING(c.column_default FROM', $show_create_sql ); + $this->assertStringContainsString( "THEN 'CURRENT_TIMESTAMP'", $show_create_sql ); + $this->assertStringContainsString( "'CURRENT_TIMESTAMP(' || SUBSTRING(c.column_default FROM", $show_create_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_index i', $show_create_sql ); + $this->assertStringContainsString( 'pg_catalog.obj_description(idx.oid, \'pg_class\')', $show_create_sql ); + $this->assertStringContainsString( '__wp_mysql_index_sub_part:', $show_create_sql ); + $this->assertStringContainsString( 'FROM information_schema.key_column_usage kcu', $show_create_sql ); + $this->assertStringContainsString( 'FROM information_schema.referential_constraints rc', $show_create_sql ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_constraint con', $show_create_sql ); + $this->assertStringContainsString( "con.contype = 'c'", $show_create_sql ); + $this->assertStringContainsString( 'pg_catalog.obj_description(con.oid, \'pg_constraint\')', $show_create_sql ); + $this->assertStringContainsString( '__wp_mysql_check_clause:', $show_create_sql ); + $this->assertStringContainsString( 'AS "TABLE_COMMENT"', $show_create_sql ); + + $columns = $driver->query( 'SHOW COLUMNS FROM `' . $child_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS', + $backend_sql + ); + $this->assertCount( 6, $columns ); + $id_column = $this->find_row_by_value( $columns, 'Field', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_column, 'Type' ) ); + $this->assertSame( 'NO', $this->get_row_value( $id_column, 'Null' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_column, 'Key' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'Extra' ) ); + $description_column = $this->find_row_by_value( $columns, 'Field', 'description' ); + $this->assertSame( 'varchar(255)', $this->get_row_value( $description_column, 'Type' ) ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $child_table . '`' ); + $show_index_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX', + $backend_sql + ); + $this->assertGreaterThanOrEqual( 4, count( $indexes ) ); + $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'] ); + + $primary_index = $this->find_row_by_value( $indexes, 'Key_name', 'PRIMARY' ); + $this->assertCount( 15, get_object_vars( $primary_index ) ); + $this->assertSame( $child_table, $this->get_row_value( $primary_index, 'Table' ) ); + $this->assertSame( '0', $this->get_row_value( $primary_index, 'Non_unique' ) ); + $this->assertSame( '1', $this->get_row_value( $primary_index, 'Seq_in_index' ) ); + $this->assertSame( 'id', $this->get_row_value( $primary_index, 'Column_name' ) ); + $this->assertSame( 'A', $this->get_row_value( $primary_index, 'Collation' ) ); + $this->assertSame( '0', $this->get_row_value( $primary_index, 'Cardinality' ) ); + $this->assertNull( $this->get_row_value( $primary_index, 'Sub_part' ) ); + $this->assertNull( $this->get_row_value( $primary_index, 'Packed' ) ); + $this->assertSame( '', $this->get_row_value( $primary_index, 'Null' ) ); + $this->assertSame( 'BTREE', $this->get_row_value( $primary_index, 'Index_type' ) ); + $this->assertSame( '', $this->get_row_value( $primary_index, 'Comment' ) ); + $this->assertSame( '', $this->get_row_value( $primary_index, 'Index_comment' ) ); + $this->assertSame( 'YES', $this->get_row_value( $primary_index, 'Visible' ) ); + $this->assertNull( $this->get_row_value( $primary_index, 'Expression' ) ); + + $slug_index = $this->find_row_by_value( $indexes, 'Key_name', 'slug_unique' ); + $this->assertSame( 'slug', $this->get_row_value( $slug_index, 'Column_name' ) ); + $this->assertSame( '0', $this->get_row_value( $slug_index, 'Non_unique' ) ); + + $parent_index = $this->find_row_by_value( $indexes, 'Key_name', 'parent_idx' ); + $this->assertSame( 'parent_id', $this->get_row_value( $parent_index, 'Column_name' ) ); + $this->assertSame( '1', $this->get_row_value( $parent_index, 'Non_unique' ) ); + + $prefix_index = $this->find_row_by_value( $indexes, 'Key_name', 'description_prefix' ); + $this->assertSame( 'description', $this->get_row_value( $prefix_index, 'Column_name' ) ); + $this->assertSame( '191', $this->get_row_value( $prefix_index, 'Sub_part' ) ); + $this->assertCount( 1, $show_index_queries ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $show_index_queries[0]['sql'] ); + $this->assertStringContainsString( 'pg_catalog.unnest(i.indkey)', $show_index_queries[0]['sql'] ); + $this->assertStringContainsString( 'show_index_rows', $show_index_queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $show_index_queries[0]['sql'] ); + $this->assertSame( array( 'public', $child_table ), $show_index_queries[0]['params'] ); + + $keys = $driver->query( 'SHOW KEYS FROM `' . $child_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW KEYS', + $backend_sql + ); + $this->assertEquals( $indexes, $keys ); + $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'] ); + + $table_status_rows = $driver->query( 'SHOW TABLE STATUS LIKE ' . $child_literal ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLE STATUS', + $backend_sql + ); + $table_status_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertCount( 1, $table_status_rows ); + $this->assertSame( $child_table, $this->get_row_value( $table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Task 813 child table', $this->get_row_value( $table_status_rows[0], 'Comment' ) ); + $this->assertStringContainsString( 'pg_catalog.obj_description(pc.oid, \'pg_class\')', $table_status_sql ); + $this->assertStringContainsString( 'AS "TABLE_COLLATION"', $table_status_sql ); + $this->assertStringContainsString( 'FROM information_schema.columns table_collation_columns', $table_status_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_class pc', $table_status_sql ); + + $table_rows = $driver->query( + "SELECT table_name, engine, table_collation, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$child_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables', + $backend_sql + ); + $this->assertCount( 1, $table_rows ); + $this->assertSame( $child_table, $this->get_row_value( $table_rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_rows[0], 'ENGINE' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_rows[0], 'TABLE_COLLATION' ) ); + $this->assertSame( 'Task 813 child table', $this->get_row_value( $table_rows[0], 'TABLE_COMMENT' ) ); + + $column_rows = $driver->query( + "SELECT column_name, column_type, column_key, extra, column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = {$child_literal} + ORDER BY ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns', + $backend_sql + ); + $this->assertCount( 6, $column_rows ); + $id_information_schema_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_information_schema_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_information_schema_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_information_schema_column, 'EXTRA' ) ); + $description_information_schema_column = $this->find_row_by_value( + $column_rows, + 'COLUMN_NAME', + 'description' + ); + $this->assertSame( + 'Child description note', + $this->get_row_value( $description_information_schema_column, 'COLUMN_COMMENT' ) + ); + + $statistics_rows = $driver->query( + "SELECT index_name, column_name, sub_part, non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$child_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics', + $backend_sql + ); + $this->assertGreaterThanOrEqual( 4, count( $statistics_rows ) ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'PRIMARY' ) ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'slug_unique' ) ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'parent_idx' ) ); + $prefix_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'description_prefix' ); + $this->assertSame( 'description', $this->get_row_value( $prefix_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '191', $this->get_row_value( $prefix_statistic, 'SUB_PART' ) ); + + $table_constraints = $driver->query( + "SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = {$child_literal} + AND constraint_name IN ({$fk_literal}, {$check_literal}) + ORDER BY constraint_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.table_constraints', + $backend_sql + ); + $this->assertCount( 2, $table_constraints ); + $this->assertSame( + 'FOREIGN KEY', + $this->get_row_value( $this->find_row_by_value( $table_constraints, 'CONSTRAINT_NAME', $foreign_key ), 'CONSTRAINT_TYPE' ) + ); + $this->assertSame( + 'CHECK', + $this->get_row_value( $this->find_row_by_value( $table_constraints, 'CONSTRAINT_NAME', $check_constraint ), 'CONSTRAINT_TYPE' ) + ); + + $key_column_usage_rows = $driver->query( + "SELECT constraint_name, column_name, referenced_table_name, referenced_column_name + FROM information_schema.key_column_usage + WHERE constraint_schema = DATABASE() + AND table_name = {$child_literal} + AND referenced_table_name = {$parent_literal} + ORDER BY constraint_name, ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.key_column_usage', + $backend_sql + ); + $this->assertCount( 1, $key_column_usage_rows ); + $this->assertSame( $foreign_key, $this->get_row_value( $key_column_usage_rows[0], 'CONSTRAINT_NAME' ) ); + $this->assertSame( 'parent_id', $this->get_row_value( $key_column_usage_rows[0], 'COLUMN_NAME' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $key_column_usage_rows[0], 'REFERENCED_TABLE_NAME' ) ); + $this->assertSame( 'id', $this->get_row_value( $key_column_usage_rows[0], 'REFERENCED_COLUMN_NAME' ) ); + + $referential_constraints = $driver->query( + "SELECT constraint_name, update_rule, delete_rule + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = {$fk_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.referential_constraints', + $backend_sql + ); + $this->assertCount( 1, $referential_constraints ); + $this->assertSame( $foreign_key, $this->get_row_value( $referential_constraints[0], 'CONSTRAINT_NAME' ) ); + $this->assertSame( 'CASCADE', $this->get_row_value( $referential_constraints[0], 'DELETE_RULE' ) ); + $this->assertContains( + $this->get_row_value( $referential_constraints[0], 'UPDATE_RULE' ), + array( 'RESTRICT', 'NO ACTION' ) + ); + + $check_constraints = $driver->query( + "SELECT constraint_name, check_clause + FROM information_schema.check_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name = {$check_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.check_constraints', + $backend_sql + ); + $this->assertCount( 1, $check_constraints ); + $this->assertSame( $check_constraint, $this->get_row_value( $check_constraints[0], 'CONSTRAINT_NAME' ) ); + $this->assertStringContainsString( 'blocked', (string) $this->get_row_value( $check_constraints[0], 'CHECK_CLAUSE' ) ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_views_with_prefix( $pdo, 'task813' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task813' ); + $this->assertSame( array(), $this->get_public_pgsql_views_with_prefix( $pdo, 'task813' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task813' ) ); + } + } + + /** + * Tests real PostgreSQL DDL mutations refresh catalog readback. + */ + public function test_real_pgsql_ddl_mutations_refresh_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL DDL mutation catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task815' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task815_' . $suffix . '_ddl'; + $no_primary_table = 'task815_' . $suffix . '_no_primary'; + $table_literal = $driver->get_connection()->quote( $table ); + $named_check = 'rating_positive'; + $generated_check = $table . '_chk_1'; + $named_check_literal = $driver->get_connection()->quote( $named_check ); + $generated_check_literal = $driver->get_connection()->quote( $generated_check ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL, + `status` varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `name_unique` (`name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 815 base table'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE DDL mutation table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `name` varchar(64) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $no_primary_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE DDL no-primary table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` + ADD COLUMN `rating` int NOT NULL DEFAULT 0 COMMENT 'Rating note'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD COLUMN rating', + $backend_sql + ); + + $add_columns_query = sprintf( + 'ALTER TABLE `%s` + ADD COLUMN `code` varchar(64) NULL, + ADD COLUMN `slug` varchar(255) NULL', + $table + ); + $this->assertSame( 0, $driver->query( $add_columns_query ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD COLUMN code and slug', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` + MODIFY COLUMN `code` varchar(80) NOT NULL DEFAULT 'seed' UNIQUE COMMENT 'Code note'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE MODIFY COLUMN code UNIQUE', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` + MODIFY COLUMN `slug` varchar(255) KEY COMMENT 'Slug note'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE MODIFY COLUMN slug KEY', + $backend_sql + ); + + $add_indexes_query = sprintf( + 'ALTER TABLE `%s` + ADD INDEX `rating_idx` (`rating`), + ADD UNIQUE KEY `status_unique` (`status`)', + $table + ); + $this->assertSame( 0, $driver->query( $add_indexes_query ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD INDEX and ADD UNIQUE', + $backend_sql + ); + + $add_checks_query = sprintf( + 'ALTER TABLE `%s` + ADD CONSTRAINT `%s` CHECK (`rating` >= 0), + ADD CHECK (`rating` < 100)', + $table, + $named_check + ); + $this->assertSame( 0, $driver->query( $add_checks_query ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD CHECK constraints', + $backend_sql + ); + $add_check_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" ADD CONSTRAINT "' . $named_check . '" CHECK ("rating" >= 0)', $add_check_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" ADD CONSTRAINT "' . $generated_check . '" CHECK ("rating" < 100)', $add_check_sql ); + + $check_constraint_rows = $driver->query( + "SELECT constraint_name, check_clause + FROM information_schema.check_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name IN ({$named_check_literal}, {$generated_check_literal}) + ORDER BY constraint_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.check_constraints after ADD CHECK', + $backend_sql + ); + $this->assertCount( 2, $check_constraint_rows ); + $named_check_row = $this->find_row_by_value( $check_constraint_rows, 'CONSTRAINT_NAME', $named_check ); + $this->assertStringContainsString( 'rating', (string) $this->get_row_value( $named_check_row, 'CHECK_CLAUSE' ) ); + $generated_check_row = $this->find_row_by_value( $check_constraint_rows, 'CONSTRAINT_NAME', $generated_check ); + $this->assertStringContainsString( '100', (string) $this->get_row_value( $generated_check_row, 'CHECK_CLAUSE' ) ); + + $drop_checks_query = sprintf( + 'ALTER TABLE `%s` + DROP CONSTRAINT `%s`, + DROP CHECK `%s`', + $table, + $named_check, + $generated_check + ); + $this->assertSame( 0, $driver->query( $drop_checks_query ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP CHECK constraints', + $backend_sql + ); + $drop_check_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" DROP CONSTRAINT "' . $named_check . '"', $drop_check_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" DROP CONSTRAINT "' . $generated_check . '"', $drop_check_sql ); + + $remaining_check_constraint_rows = $driver->query( + "SELECT constraint_name + FROM information_schema.check_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name IN ({$named_check_literal}, {$generated_check_literal})" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.check_constraints after DROP CHECK', + $backend_sql + ); + $this->assertSame( array(), $remaining_check_constraint_rows ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'DROP INDEX `rating_idx` ON `%s`', $table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP INDEX rating_idx', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` + COMMENT = 'Task 815 mutated table'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE COMMENT', + $backend_sql + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE after DDL mutations', + $backend_sql + ); + $this->assertCount( 1, $create_rows ); + $create_table_sql = (string) $this->get_row_value( $create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table . '`', $create_table_sql ); + $this->assertStringContainsString( '`rating` int NOT NULL DEFAULT \'0\' COMMENT \'Rating note\'', $create_table_sql ); + $this->assertStringContainsString( '`code` varchar(80) NOT NULL DEFAULT \'seed\' COMMENT \'Code note\'', $create_table_sql ); + $this->assertStringContainsString( '`slug` varchar(255) DEFAULT NULL COMMENT \'Slug note\'', $create_table_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `code` (`code`)', $create_table_sql ); + $this->assertStringContainsString( 'KEY `slug` (`slug`(191))', $create_table_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `status_unique` (`status`)', $create_table_sql ); + $this->assertStringNotContainsString( 'rating_idx', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 815 mutated table\'', $create_table_sql ); + + $columns = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after DDL mutations', + $backend_sql + ); + $this->assertCount( 6, $columns ); + $rating_column = $this->find_row_by_value( $columns, 'Field', 'rating' ); + $this->assertSame( 'int', $this->get_row_value( $rating_column, 'Type' ) ); + $this->assertSame( 'NO', $this->get_row_value( $rating_column, 'Null' ) ); + $this->assertSame( '0', $this->get_row_value( $rating_column, 'Default' ) ); + $code_column = $this->find_row_by_value( $columns, 'Field', 'code' ); + $this->assertSame( 'varchar(80)', $this->get_row_value( $code_column, 'Type' ) ); + $this->assertSame( 'UNI', $this->get_row_value( $code_column, 'Key' ) ); + $this->assertSame( 'seed', $this->get_row_value( $code_column, 'Default' ) ); + $slug_column = $this->find_row_by_value( $columns, 'Field', 'slug' ); + $this->assertSame( 'varchar(255)', $this->get_row_value( $slug_column, 'Type' ) ); + $this->assertSame( 'MUL', $this->get_row_value( $slug_column, 'Key' ) ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after DDL mutations', + $backend_sql + ); + $index_names = array(); + foreach ( $indexes as $index ) { + $index_names[] = (string) $this->get_row_value( $index, 'Key_name' ); + } + $this->assertContains( 'PRIMARY', $index_names ); + $this->assertContains( 'name_unique', $index_names ); + $this->assertContains( 'code', $index_names ); + $this->assertContains( 'slug', $index_names ); + $this->assertContains( 'status_unique', $index_names ); + $this->assertNotContains( 'rating_idx', $index_names ); + $slug_index = $this->find_row_by_value( $indexes, 'Key_name', 'slug' ); + $this->assertSame( 'slug', $this->get_row_value( $slug_index, 'Column_name' ) ); + $this->assertSame( '191', $this->get_row_value( $slug_index, 'Sub_part' ) ); + + $this->assertSame( 0, $driver->query( sprintf( 'ALTER TABLE `%s` DROP PRIMARY KEY', $table ) ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP PRIMARY KEY', + $backend_sql + ); + $drop_primary_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" DROP CONSTRAINT "' . $table . '_pkey"', $drop_primary_sql ); + + $this->assertSame( 0, $driver->query( sprintf( 'ALTER TABLE `%s` ADD PRIMARY KEY (`id`)', $table ) ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD PRIMARY KEY', + $backend_sql + ); + $add_primary_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" ADD PRIMARY KEY ("id")', $add_primary_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` DROP PRIMARY KEY', $no_primary_table ) ); + $this->fail( 'Expected DROP PRIMARY KEY without a PostgreSQL primary-key constraint to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP PRIMARY KEY without primary key', + $backend_sql + ); + + try { + $driver->query( sprintf( 'DROP INDEX PRIMARY ON `%s`', $no_primary_table ) ); + $this->fail( 'Expected DROP INDEX PRIMARY without a PostgreSQL primary-key constraint to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DROP INDEX statement.', $e->getMessage() ); + } + $this->collect_last_postgresql_queries( + $driver, + 'DROP INDEX PRIMARY without primary key', + $backend_sql + ); + + $table_status_rows = $driver->query( 'SHOW TABLE STATUS LIKE ' . $table_literal ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLE STATUS after DDL mutations', + $backend_sql + ); + $this->assertCount( 1, $table_status_rows ); + $this->assertSame( $table, $this->get_row_value( $table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Task 815 mutated table', $this->get_row_value( $table_status_rows[0], 'Comment' ) ); + + $table_rows = $driver->query( + "SELECT table_name, engine, table_collation, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$table_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables after DDL mutations', + $backend_sql + ); + $this->assertCount( 1, $table_rows ); + $this->assertSame( $table, $this->get_row_value( $table_rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_rows[0], 'ENGINE' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_rows[0], 'TABLE_COLLATION' ) ); + $this->assertSame( 'Task 815 mutated table', $this->get_row_value( $table_rows[0], 'TABLE_COMMENT' ) ); + + $column_rows = $driver->query( + "SELECT column_name, column_type, column_key, column_default, column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns after DDL mutations', + $backend_sql + ); + $this->assertCount( 6, $column_rows ); + $rating_information_schema_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'rating' ); + $this->assertSame( 'int', $this->get_row_value( $rating_information_schema_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'Rating note', $this->get_row_value( $rating_information_schema_column, 'COLUMN_COMMENT' ) ); + $code_information_schema_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'code' ); + $this->assertSame( 'varchar(80)', $this->get_row_value( $code_information_schema_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'UNI', $this->get_row_value( $code_information_schema_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'Code note', $this->get_row_value( $code_information_schema_column, 'COLUMN_COMMENT' ) ); + $slug_information_schema_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'slug' ); + $this->assertSame( 'varchar(255)', $this->get_row_value( $slug_information_schema_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'MUL', $this->get_row_value( $slug_information_schema_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'Slug note', $this->get_row_value( $slug_information_schema_column, 'COLUMN_COMMENT' ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, column_name, sub_part, non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after DDL mutations', + $backend_sql + ); + $statistics_index_names = array(); + foreach ( $statistics_rows as $statistic ) { + $statistics_index_names[] = (string) $this->get_row_value( $statistic, 'INDEX_NAME' ); + } + $this->assertContains( 'PRIMARY', $statistics_index_names ); + $this->assertContains( 'name_unique', $statistics_index_names ); + $this->assertContains( 'code', $statistics_index_names ); + $this->assertContains( 'slug', $statistics_index_names ); + $this->assertContains( 'status_unique', $statistics_index_names ); + $this->assertNotContains( 'rating_idx', $statistics_index_names ); + $slug_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'slug' ); + $this->assertSame( 'slug', $this->get_row_value( $slug_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '191', $this->get_row_value( $slug_statistic, 'SUB_PART' ) ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task815' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task815' ) ); + } + } + + /** + * Tests real PostgreSQL ALTER TABLE COMMENT preserves table collation catalog metadata. + */ + public function test_real_pgsql_alter_table_comment_preserves_collation(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL ALTER TABLE COMMENT collation test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task932' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task932_' . $suffix . '_comment'; + $table_literal = $driver->get_connection()->quote( $table ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci COMMENT='Original table note'", + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE collated table with comment', + $backend_sql + ); + + $alter_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` COMMENT = 'Altered table note'", + $table + ) + ) + ); + $alter_logged_sql = implode( "\n", array_slice( $logged_sql, $alter_start ) ); + $this->assertStringContainsString( 'AS "TABLE_COMMENT"', $alter_logged_sql ); + $this->assertStringContainsString( 'AS "TABLE_COLLATION"', $alter_logged_sql ); + $this->assertStringContainsString( 'COMMENT ON TABLE "public"."' . $table . '" IS ', $alter_logged_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE COMMENT preserves collation', + $backend_sql + ); + + $comment_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( '__wp_mysql_table_collation:bGF0aW4xX3N3ZWRpc2hfY2k=', $comment_sql ); + $this->assertStringContainsString( 'Altered table note', $comment_sql ); + $this->assertStringNotContainsString( 'Original table note', $comment_sql ); + + $raw_comment_stmt = $pdo->prepare( + "SELECT pg_catalog.obj_description(t.oid, 'pg_class') + FROM pg_catalog.pg_class t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = ?" + ); + $raw_comment_stmt->execute( array( $table ) ); + $raw_comment = (string) $raw_comment_stmt->fetchColumn(); + $this->assertStringContainsString( '__wp_mysql_table_collation:bGF0aW4xX3N3ZWRpc2hfY2k=', $raw_comment ); + $this->assertStringContainsString( 'Altered table note', $raw_comment ); + $this->assertStringNotContainsString( 'Original table note', $raw_comment ); + + $create_rows = $driver->query( sprintf( 'SHOW CREATE TABLE `%s`', $table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE after ALTER TABLE COMMENT preserves collation', + $backend_sql + ); + $this->assertCount( 1, $create_rows ); + $create_table_sql = (string) $this->get_row_value( $create_rows[0], 'Create Table' ); + $this->assertStringContainsString( ') ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci', $create_table_sql ); + $this->assertStringContainsString( "COMMENT='Altered table note'", $create_table_sql ); + $this->assertStringNotContainsString( '__wp_mysql_table_collation:', $create_table_sql ); + + $table_rows = $driver->query( + "SELECT table_name, table_collation, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$table_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables after ALTER TABLE COMMENT preserves collation', + $backend_sql + ); + $this->assertCount( 1, $table_rows ); + $this->assertSame( $table, $this->get_row_value( $table_rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'latin1_swedish_ci', $this->get_row_value( $table_rows[0], 'TABLE_COLLATION' ) ); + $this->assertSame( 'Altered table note', $this->get_row_value( $table_rows[0], 'TABLE_COMMENT' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task932' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task932' ) ); + } + } + + /** + * Tests real PostgreSQL DROP COLUMN and same-name ADD INDEX use catalogs. + */ + public function test_real_pgsql_drop_indexed_column_readd_index_uses_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL drop-column/index catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task898' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task898_' . $suffix . '_drop_readd'; + $table_literal = $driver->get_connection()->quote( $table ); + $lookup_literal = $driver->get_connection()->quote( 'lookup' ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `obsolete` varchar(64) NOT NULL, + `replacement` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE drop-column readd-index table', + $backend_sql + ); + + $this->assertSame( 0, $driver->query( sprintf( 'ALTER TABLE `%s` ADD INDEX `lookup` (`obsolete`)', $table ) ) ); + $add_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD lookup index on obsolete', + $backend_sql + ); + $this->assertStringContainsString( + 'CREATE INDEX "' . $table . '__lookup" ON "' . $table . '" ("obsolete")', + $add_index_sql + ); + + $initial_statistics_rows = $driver->query( + "SELECT index_name, column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + AND index_name = {$lookup_literal} + ORDER BY seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics before DROP COLUMN', + $backend_sql + ); + $this->assertCount( 1, $initial_statistics_rows ); + $this->assertSame( 'obsolete', $this->get_row_value( $initial_statistics_rows[0], 'COLUMN_NAME' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + DROP COLUMN `obsolete`, + ADD INDEX `lookup` (`replacement`)', + $table + ) + ) + ); + $drop_readd_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP COLUMN and readd same index name', + $backend_sql + ); + $this->assertStringContainsString( 'WITH index_columns AS', $drop_readd_sql ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_class t', $drop_readd_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $table . '" DROP COLUMN "obsolete"', $drop_readd_sql ); + $this->assertStringContainsString( + 'CREATE INDEX "' . $table . '__lookup" ON "' . $table . '" ("replacement")', + $drop_readd_sql + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after DROP COLUMN and readd index', + $backend_sql + ); + $column_names = array_map( + function ( $column ): string { + return (string) $this->get_row_value( $column, 'Field' ); + }, + $columns + ); + $this->assertContains( 'replacement', $column_names ); + $this->assertNotContains( 'obsolete', $column_names ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after DROP COLUMN and readd index', + $backend_sql + ); + $lookup_index = $this->find_row_by_value( $indexes, 'Key_name', 'lookup' ); + $this->assertSame( 'replacement', $this->get_row_value( $lookup_index, 'Column_name' ) ); + + $final_statistics_rows = $driver->query( + "SELECT index_name, column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + AND index_name = {$lookup_literal} + ORDER BY seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after DROP COLUMN and readd index', + $backend_sql + ); + $this->assertCount( 1, $final_statistics_rows ); + $this->assertSame( 'replacement', $this->get_row_value( $final_statistics_rows[0], 'COLUMN_NAME' ) ); + + $column_rows = $driver->query( + "SELECT column_name + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns after DROP COLUMN and readd index', + $backend_sql + ); + $information_schema_column_names = array_map( + function ( $column ): string { + return (string) $this->get_row_value( $column, 'COLUMN_NAME' ); + }, + $column_rows + ); + $this->assertContains( 'replacement', $information_schema_column_names ); + $this->assertNotContains( 'obsolete', $information_schema_column_names ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task898' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task898' ) ); + } + } + + /** + * Tests real PostgreSQL RENAME TABLE renames catalog-backed physical indexes. + */ + public function test_real_pgsql_rename_table_renames_catalog_indexes(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL rename-table index catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task902' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $old_table = 'task902_' . $suffix . '_old'; + $new_table = 'task902_' . $suffix . '_new'; + $child_table = 'task902_' . $suffix . '_child'; + $new_literal = $driver->get_connection()->quote( $new_table ); + $child_literal = $driver->get_connection()->quote( $child_table ); + $fk_literal = $driver->get_connection()->quote( 'parent_fk' ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `value_idx` (`value`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $old_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE rename-table index table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `parent_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `parent_idx` (`parent_id`), + CONSTRAINT `parent_fk` FOREIGN KEY (`parent_id`) REFERENCES `%s` (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $child_table, + $old_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE rename-table child foreign-key table', + $backend_sql + ); + + $initial_indexes = $driver->query( 'SHOW INDEX FROM `' . $old_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX before RENAME TABLE', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $initial_indexes, 'Key_name', 'slug_unique' ) ); + $this->assertNotNull( $this->find_row_by_value( $initial_indexes, 'Key_name', 'value_idx' ) ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'RENAME TABLE `%s` TO `%s`', $old_table, $new_table ) ) + ); + $rename_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'RENAME TABLE with catalog-backed index rename', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER TABLE "public"."' . $old_table . '" RENAME TO "' . $new_table . '"', $rename_sql ); + $this->assertStringContainsString( 'ALTER INDEX "public"."' . $old_table . '__slug_unique" RENAME TO "' . $new_table . '__slug_unique"', $rename_sql ); + $this->assertStringContainsString( 'ALTER INDEX "public"."' . $old_table . '__value_idx" RENAME TO "' . $new_table . '__value_idx"', $rename_sql ); + + $this->assertSame( array( $child_table, $new_table ), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task902' ) ); + + $physical_indexes = $this->get_public_pgsql_index_names_for_table( $pdo, $new_table ); + $this->assertContains( $new_table . '__slug_unique', $physical_indexes ); + $this->assertContains( $new_table . '__value_idx', $physical_indexes ); + $this->assertNotContains( $old_table . '__slug_unique', $physical_indexes ); + $this->assertNotContains( $old_table . '__value_idx', $physical_indexes ); + + $renamed_indexes = $driver->query( 'SHOW INDEX FROM `' . $new_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after RENAME TABLE', + $backend_sql + ); + $slug_index = $this->find_row_by_value( $renamed_indexes, 'Key_name', 'slug_unique' ); + $this->assertSame( 'slug', $this->get_row_value( $slug_index, 'Column_name' ) ); + $this->assertSame( '0', $this->get_row_value( $slug_index, 'Non_unique' ) ); + $value_index = $this->find_row_by_value( $renamed_indexes, 'Key_name', 'value_idx' ); + $this->assertSame( 'value', $this->get_row_value( $value_index, 'Column_name' ) ); + $this->assertSame( '1', $this->get_row_value( $value_index, 'Non_unique' ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, column_name, non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$new_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after RENAME TABLE', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'slug_unique' ) ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'value_idx' ) ); + + $foreign_key_rows = $driver->query( + "SELECT constraint_name, table_name, referenced_table_name + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND table_name = {$child_literal} + AND constraint_name = {$fk_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.referential_constraints after RENAME TABLE', + $backend_sql + ); + $this->assertCount( 1, $foreign_key_rows ); + $this->assertSame( 'parent_fk', $this->get_row_value( $foreign_key_rows[0], 'CONSTRAINT_NAME' ) ); + $this->assertSame( $child_table, $this->get_row_value( $foreign_key_rows[0], 'TABLE_NAME' ) ); + $this->assertSame( $new_table, $this->get_row_value( $foreign_key_rows[0], 'REFERENCED_TABLE_NAME' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task902' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task902' ) ); + } + } + + /** + * Tests real PostgreSQL ON UPDATE trigger rename/drop use catalogs. + */ + public function test_real_pgsql_on_update_trigger_rename_and_drop_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL ON UPDATE rename/drop catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task906' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $old_table = 'task906_' . $suffix . '_old'; + $new_table = 'task906_' . $suffix . '_new'; + $old_hash = md5( "public\0{$old_table}\0updated_at" ); + $new_hash = md5( "public\0{$new_table}\0updated_at" ); + $old_trigger = '__wp_pg_on_update_' . $old_hash; + $new_trigger = '__wp_pg_on_update_' . $new_hash; + $old_function = '__wp_pg_on_update_fn_' . $old_hash; + $new_function = '__wp_pg_on_update_fn_' . $new_hash; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `payload` varchar(64) NOT NULL, + `updated_at` timestamp NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $old_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE ON UPDATE table', + $backend_sql + ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $old_table, $old_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $old_function ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`payload`) VALUES ('seed')", $old_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT ON UPDATE row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `payload` = 'before-rename' WHERE `id` = 1", $old_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'UPDATE ON UPDATE row before rename', + $backend_sql + ); + + $updated_rows = $driver->query( sprintf( 'SELECT `updated_at` FROM `%s` WHERE `id` = 1', $old_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT ON UPDATE timestamp before rename', + $backend_sql + ); + $this->assertCount( 1, $updated_rows ); + $this->assertNotSame( '', (string) $this->get_row_value( $updated_rows[0], 'updated_at' ) ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'RENAME TABLE `%s` TO `%s`', $old_table, $new_table ) ) + ); + $rename_sql = implode( "\n", $logged_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'RENAME TABLE ON UPDATE trigger', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_class t', $rename_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_trigger tr', $rename_sql ); + $this->assertStringContainsString( 'ALTER TABLE "public"."' . $old_table . '" RENAME TO "' . $new_table . '"', $rename_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $old_trigger . '" ON "public"."' . $new_table . '"', $rename_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "public"."' . $old_function . '"()', $rename_sql ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION "public"."' . $new_function . '"()', $rename_sql ); + $this->assertStringContainsString( 'CREATE TRIGGER "' . $new_trigger . '" BEFORE UPDATE ON "public"."' . $new_table . '"', $rename_sql ); + $this->assertFalse( $this->public_pgsql_trigger_exists( $pdo, $new_table, $old_trigger ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $old_function ) ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $new_table, $new_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $new_function ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `payload` = 'after-rename' WHERE `id` = 1", $new_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'UPDATE ON UPDATE row after rename', + $backend_sql + ); + + $renamed_rows = $driver->query( sprintf( 'SELECT `payload`, `updated_at` FROM `%s` WHERE `id` = 1', $new_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT ON UPDATE timestamp after rename', + $backend_sql + ); + $this->assertCount( 1, $renamed_rows ); + $this->assertSame( 'after-rename', $this->get_row_value( $renamed_rows[0], 'payload' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $renamed_rows[0], 'updated_at' ) ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'DROP TABLE `public`.`%s`', $new_table ) ) + ); + $drop_sql = implode( "\n", $logged_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP TABLE ON UPDATE trigger', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_class t', $drop_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_trigger tr', $drop_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $new_trigger . '" ON "public"."' . $new_table . '"', $drop_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "public"."' . $new_function . '"()', $drop_sql ); + $this->assertStringContainsString( 'DROP TABLE "' . $new_table . '"', $drop_sql ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task906' ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $new_function ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task906' ); + $this->drop_public_pgsql_function( $pdo, $old_function ); + $this->drop_public_pgsql_function( $pdo, $new_function ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task906' ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $old_function ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $new_function ) ); + } + } + + /** + * Tests real PostgreSQL current-schema DROP TABLE cleans ON UPDATE triggers. + */ + public function test_real_pgsql_current_schema_drop_table_cleans_on_update_trigger(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL current-schema DROP TABLE ON UPDATE cleanup test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task999' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task999_' . $suffix; + $table_name = 'drop_target'; + $hash = md5( "{$schema_name}\0{$table_name}\0updated_at" ); + $trigger_name = '__wp_pg_on_update_' . $hash; + $function_name = '__wp_pg_on_update_fn_' . $hash; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $backend_sql = array(); + + $trigger_exists = function ( string $schema, string $table, string $trigger ) use ( $pdo ): bool { + $stmt = $pdo->prepare( + 'SELECT 1 + FROM pg_catalog.pg_trigger tr + INNER JOIN pg_catalog.pg_class t + ON t.oid = tr.tgrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = ? + AND t.relname = ? + AND tr.tgname = ? + AND NOT tr.tgisinternal + LIMIT 1' + ); + $stmt->execute( array( $schema, $table, $trigger ) ); + + return false !== $stmt->fetchColumn(); + }; + + $function_exists = function ( string $schema, string $function_name_to_check ) use ( $pdo ): bool { + $stmt = $pdo->prepare( + "SELECT 1 + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + WHERE n.nspname = ? + AND p.prokind = 'f' + AND p.proname = ? + LIMIT 1" + ); + $stmt->execute( array( $schema, $function_name_to_check ) ); + + return false !== $stmt->fetchColumn(); + }; + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE task999 non-public schema', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `payload` varchar(64) NOT NULL, + `updated_at` timestamp NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema ON UPDATE table', + $backend_sql + ); + $this->assertTrue( $trigger_exists( $schema_name, $table_name, $trigger_name ) ); + $this->assertTrue( $function_exists( $schema_name, $function_name ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`payload`) VALUES ('seed')", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT current-schema ON UPDATE row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `payload` = 'updated' WHERE `id` = 1", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'UPDATE current-schema ON UPDATE row', + $backend_sql + ); + + $updated_rows = $driver->query( sprintf( 'SELECT `updated_at` FROM `%s` WHERE `id` = 1', $table_name ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT current-schema ON UPDATE timestamp', + $backend_sql + ); + $this->assertCount( 1, $updated_rows ); + $this->assertNotSame( '', (string) $this->get_row_value( $updated_rows[0], 'updated_at' ) ); + + $drop_log_start = count( $logged_sql ); + $this->assertSame( 0, $driver->query( 'DROP TABLE `' . $table_name . '`' ) ); + $drop_sql = implode( "\n", array_slice( $logged_sql, $drop_log_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP current-schema ON UPDATE table', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_class t', $drop_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_trigger tr', $drop_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $trigger_name . '" ON "' . $schema_name . '"."' . $table_name . '"', $drop_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "' . $schema_name . '"."' . $function_name . '"()', $drop_sql ); + $this->assertStringContainsString( 'DROP TABLE "' . $schema_name . '"."' . $table_name . '"', $drop_sql ); + $this->assertFalse( $function_exists( $schema_name, $function_name ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task999' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task999' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task999' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task999' ) ); + $this->assertFalse( $function_exists( $schema_name, $function_name ) ); + } + } + + /** + * Tests real PostgreSQL ON UPDATE column rename/change use catalogs. + */ + public function test_real_pgsql_on_update_trigger_column_rename_and_change_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL ON UPDATE column catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task930' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $rename_table = 'task930_' . $suffix . '_rename'; + $change_table = 'task930_' . $suffix . '_change'; + + $rename_old_hash = md5( "public\0{$rename_table}\0old_updated" ); + $rename_new_hash = md5( "public\0{$rename_table}\0renamed_updated" ); + $rename_old_trigger = '__wp_pg_on_update_' . $rename_old_hash; + $rename_new_trigger = '__wp_pg_on_update_' . $rename_new_hash; + $rename_old_function = '__wp_pg_on_update_fn_' . $rename_old_hash; + $rename_new_function = '__wp_pg_on_update_fn_' . $rename_new_hash; + + $change_old_hash = md5( "public\0{$change_table}\0old_touched" ); + $change_touched_hash = md5( "public\0{$change_table}\0touched_at" ); + $change_plain_hash = md5( "public\0{$change_table}\0touched_plain" ); + $change_old_trigger = '__wp_pg_on_update_' . $change_old_hash; + $change_touched_trigger = '__wp_pg_on_update_' . $change_touched_hash; + $change_plain_trigger = '__wp_pg_on_update_' . $change_plain_hash; + $change_old_function = '__wp_pg_on_update_fn_' . $change_old_hash; + $change_touched_function = '__wp_pg_on_update_fn_' . $change_touched_hash; + $change_plain_function = '__wp_pg_on_update_fn_' . $change_plain_hash; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `payload` varchar(64) NOT NULL, + `old_updated` timestamp NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $rename_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE ON UPDATE column rename table', + $backend_sql + ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $rename_table, $rename_old_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $rename_old_function ) ); + + $rename_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` RENAME COLUMN `old_updated` TO `renamed_updated`', + $rename_table + ) + ) + ); + $rename_sql = implode( "\n", array_slice( $logged_sql, $rename_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'RENAME COLUMN ON UPDATE trigger', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_trigger tr', $rename_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $rename_table . '" RENAME COLUMN "old_updated" TO "renamed_updated"', $rename_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $rename_old_trigger . '" ON "public"."' . $rename_table . '"', $rename_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "public"."' . $rename_old_function . '"()', $rename_sql ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION "public"."' . $rename_new_function . '"()', $rename_sql ); + $this->assertStringContainsString( 'CREATE TRIGGER "' . $rename_new_trigger . '" BEFORE UPDATE ON "public"."' . $rename_table . '"', $rename_sql ); + $this->assertFalse( $this->public_pgsql_trigger_exists( $pdo, $rename_table, $rename_old_trigger ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $rename_old_function ) ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $rename_table, $rename_new_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $rename_new_function ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`payload`) VALUES ('seed')", $rename_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT ON UPDATE column rename row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `payload` = 'after-column-rename' WHERE `id` = 1", $rename_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'UPDATE ON UPDATE column rename row', + $backend_sql + ); + + $renamed_rows = $driver->query( sprintf( 'SELECT `payload`, `renamed_updated` FROM `%s` WHERE `id` = 1', $rename_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT ON UPDATE column rename timestamp', + $backend_sql + ); + $this->assertCount( 1, $renamed_rows ); + $this->assertSame( 'after-column-rename', $this->get_row_value( $renamed_rows[0], 'payload' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $renamed_rows[0], 'renamed_updated' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `payload` varchar(64) NOT NULL, + `old_touched` timestamp NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $change_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE ON UPDATE change column table', + $backend_sql + ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $change_table, $change_old_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $change_old_function ) ); + + $change_set_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` CHANGE COLUMN `old_touched` `touched_at` timestamp NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Touched note'", + $change_table + ) + ) + ); + $change_set_sql = implode( "\n", array_slice( $logged_sql, $change_set_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CHANGE COLUMN preserves ON UPDATE trigger', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_trigger tr', $change_set_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $change_table . '" RENAME COLUMN "old_touched" TO "touched_at"', $change_set_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $change_old_trigger . '" ON "public"."' . $change_table . '"', $change_set_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "public"."' . $change_old_function . '"()', $change_set_sql ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION "public"."' . $change_touched_function . '"()', $change_set_sql ); + $this->assertStringContainsString( 'CREATE TRIGGER "' . $change_touched_trigger . '" BEFORE UPDATE ON "public"."' . $change_table . '"', $change_set_sql ); + $this->assertFalse( $this->public_pgsql_trigger_exists( $pdo, $change_table, $change_old_trigger ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_old_function ) ); + $this->assertTrue( $this->public_pgsql_trigger_exists( $pdo, $change_table, $change_touched_trigger ) ); + $this->assertTrue( $this->public_pgsql_function_exists( $pdo, $change_touched_function ) ); + + $columns_after_set = $driver->query( sprintf( 'SHOW COLUMNS FROM `%s`', $change_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after preserving ON UPDATE trigger', + $backend_sql + ); + $touched_at_column = $this->find_row_by_value( $columns_after_set, 'Field', 'touched_at' ); + $this->assertStringContainsString( 'on update CURRENT_TIMESTAMP', (string) $this->get_row_value( $touched_at_column, 'Extra' ) ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`payload`) VALUES ('seed')", $change_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT ON UPDATE change column row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `payload` = 'after-change' WHERE `id` = 1", $change_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'UPDATE ON UPDATE change column row', + $backend_sql + ); + + $changed_rows = $driver->query( sprintf( 'SELECT `payload`, `touched_at` FROM `%s` WHERE `id` = 1', $change_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT ON UPDATE change column timestamp', + $backend_sql + ); + $this->assertCount( 1, $changed_rows ); + $this->assertSame( 'after-change', $this->get_row_value( $changed_rows[0], 'payload' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $changed_rows[0], 'touched_at' ) ); + + $change_drop_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` CHANGE COLUMN `touched_at` `touched_plain` timestamp NULL COMMENT 'Plain note'", + $change_table + ) + ) + ); + $change_drop_sql = implode( "\n", array_slice( $logged_sql, $change_drop_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CHANGE COLUMN removes ON UPDATE trigger', + $backend_sql + ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_trigger tr', $change_drop_sql ); + $this->assertStringContainsString( 'ALTER TABLE "' . $change_table . '" RENAME COLUMN "touched_at" TO "touched_plain"', $change_drop_sql ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "' . $change_touched_trigger . '" ON "public"."' . $change_table . '"', $change_drop_sql ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "public"."' . $change_touched_function . '"()', $change_drop_sql ); + $this->assertStringNotContainsString( 'CREATE TRIGGER "' . $change_plain_trigger . '"', $change_drop_sql ); + $this->assertFalse( $this->public_pgsql_trigger_exists( $pdo, $change_table, $change_touched_trigger ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_touched_function ) ); + $this->assertFalse( $this->public_pgsql_trigger_exists( $pdo, $change_table, $change_plain_trigger ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_plain_function ) ); + + $columns_after_drop = $driver->query( sprintf( 'SHOW COLUMNS FROM `%s`', $change_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS after removing ON UPDATE trigger', + $backend_sql + ); + $touched_plain_column = $this->find_row_by_value( $columns_after_drop, 'Field', 'touched_plain' ); + $this->assertStringNotContainsString( 'on update CURRENT_TIMESTAMP', (string) $this->get_row_value( $touched_plain_column, 'Extra' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task930' ); + $this->drop_public_pgsql_function( $pdo, $rename_old_function ); + $this->drop_public_pgsql_function( $pdo, $rename_new_function ); + $this->drop_public_pgsql_function( $pdo, $change_old_function ); + $this->drop_public_pgsql_function( $pdo, $change_touched_function ); + $this->drop_public_pgsql_function( $pdo, $change_plain_function ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task930' ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $rename_old_function ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $rename_new_function ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_old_function ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_touched_function ) ); + $this->assertFalse( $this->public_pgsql_function_exists( $pdo, $change_plain_function ) ); + } + } + + /** + * Tests real PostgreSQL metadata-only index rename/drop uses catalogs. + */ + public function test_real_pgsql_metadata_only_index_rename_and_drop_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL metadata-only DROP INDEX catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task910' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task910_' . $suffix . '_indexes'; + $table_literal = $driver->get_connection()->quote( $table ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `body` text, + `shape` point, + PRIMARY KEY (`id`), + KEY `slug_idx` (`slug`), + FULLTEXT KEY `body_fulltext` (`body`), + SPATIAL KEY `shape_spatial` (`shape`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE metadata-only DROP INDEX table', + $backend_sql + ); + + $initial_indexes = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX before metadata-only DROP INDEX', + $backend_sql + ); + $this->assertSame( 'BTREE', $this->get_row_value( $this->find_row_by_value( $initial_indexes, 'Key_name', 'slug_idx' ), 'Index_type' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $initial_indexes, 'Key_name', 'body_fulltext' ), 'Index_type' ) ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $this->find_row_by_value( $initial_indexes, 'Key_name', 'shape_spatial' ), 'Index_type' ) ); + + $metadata_rename_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE `' . $table . '` RENAME INDEX `body_fulltext` TO `body_search`' ) + ); + $metadata_rename_sql = implode( "\n", array_slice( $logged_sql, $metadata_rename_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'RENAME metadata-only FULLTEXT index', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER INDEX "public"."' . $table . '__body_fulltext" RENAME TO "' . $table . '__body_search"', $metadata_rename_sql ); + + $indexes_after_metadata_rename = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after metadata-only RENAME INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_metadata_rename, 'Key_name', 'body_fulltext' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $indexes_after_metadata_rename, 'Key_name', 'body_search' ), 'Index_type' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_metadata_rename, 'Key_name', 'slug_idx' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_metadata_rename, 'Key_name', 'shape_spatial' ) ); + + $statistics_after_metadata_rename = $driver->query( + "SELECT index_name, index_type + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after metadata-only RENAME INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $statistics_after_metadata_rename, 'INDEX_NAME', 'body_fulltext' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $statistics_after_metadata_rename, 'INDEX_NAME', 'body_search' ), 'INDEX_TYPE' ) ); + + $metadata_drop_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( 'DROP INDEX `body_search` ON `' . $table . '`' ) + ); + $metadata_drop_sql = implode( "\n", array_slice( $logged_sql, $metadata_drop_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP metadata-only FULLTEXT index', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $table . '__body_search"', $metadata_drop_sql ); + + $indexes_after_metadata_drop = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after metadata-only DROP INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_metadata_drop, 'Key_name', 'body_fulltext' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_metadata_drop, 'Key_name', 'body_search' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_metadata_drop, 'Key_name', 'slug_idx' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_metadata_drop, 'Key_name', 'shape_spatial' ) ); + + $metadata_alter_drop_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE `' . $table . '` DROP INDEX `shape_spatial`' ) + ); + $metadata_alter_drop_sql = implode( "\n", array_slice( $logged_sql, $metadata_alter_drop_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP metadata-only SPATIAL index', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $table . '__shape_spatial"', $metadata_alter_drop_sql ); + + $indexes_after_metadata_alter_drop = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after metadata-only ALTER TABLE DROP INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_metadata_alter_drop, 'Key_name', 'body_search' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_metadata_alter_drop, 'Key_name', 'shape_spatial' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_metadata_alter_drop, 'Key_name', 'slug_idx' ) ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE after metadata-only DROP INDEXes', + $backend_sql + ); + $create_table_sql = (string) $this->get_row_value( $create_rows[0], 'Create Table' ); + $this->assertStringNotContainsString( 'FULLTEXT KEY `body_fulltext`', $create_table_sql ); + $this->assertStringNotContainsString( 'FULLTEXT KEY `body_search`', $create_table_sql ); + $this->assertStringNotContainsString( 'SPATIAL KEY `shape_spatial`', $create_table_sql ); + + $statistics_after_metadata_drop = $driver->query( + "SELECT index_name, index_type + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after metadata-only DROP INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $statistics_after_metadata_drop, 'INDEX_NAME', 'body_fulltext' ) ); + $this->assertNull( $this->find_optional_row_by_value( $statistics_after_metadata_drop, 'INDEX_NAME', 'body_search' ) ); + $this->assertSame( 'BTREE', $this->get_row_value( $this->find_row_by_value( $statistics_after_metadata_drop, 'INDEX_NAME', 'slug_idx' ), 'INDEX_TYPE' ) ); + $this->assertNull( $this->find_optional_row_by_value( $statistics_after_metadata_drop, 'INDEX_NAME', 'shape_spatial' ) ); + + $regular_drop_start = count( $logged_sql ); + $this->assertSame( + 0, + $driver->query( 'DROP INDEX `slug_idx` ON `' . $table . '`' ) + ); + $regular_drop_sql = implode( "\n", array_slice( $logged_sql, $regular_drop_start ) ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP regular BTREE index', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $table . '__slug_idx"', $regular_drop_sql ); + + $indexes_after_regular_drop = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after regular DROP INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_regular_drop, 'Key_name', 'body_fulltext' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_regular_drop, 'Key_name', 'body_search' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_regular_drop, 'Key_name', 'slug_idx' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_regular_drop, 'Key_name', 'shape_spatial' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task910' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task910' ) ); + } + } + + /** + * Tests real PostgreSQL metadata-only index creation uses catalog placeholders. + */ + public function test_real_pgsql_metadata_only_index_creation_uses_catalog_placeholders(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL metadata-only index creation catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task944' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $inline_table = 'task944_' . $suffix . '_inline'; + $standalone_table = 'task944_' . $suffix . '_standalone'; + $alter_table = 'task944_' . $suffix . '_alter'; + $backend_sql = array(); + + $assert_placeholder_sql = function ( string $sql, string $table_name, string $index_name, string $column_name, int $sub_part, string $type_marker ): void { + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__' . $index_name . '"', $sql ); + $this->assertStringContainsString( 'SUBSTR(CAST("' . $column_name . '" AS text), 1, ' . $sub_part . ')', $sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "public"."' . $table_name . '__' . $index_name . '" IS', $sql ); + $this->assertStringContainsString( $type_marker, $sql ); + }; + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `body` text, + `shape` point, + FULLTEXT KEY `inline_body_fulltext` (`body`), + SPATIAL KEY `inline_shape_spatial` (`shape`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $inline_table + ) + ) + ); + $inline_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public CREATE TABLE metadata-only indexes', + $backend_sql + ); + $assert_placeholder_sql( $inline_sql, $inline_table, 'inline_body_fulltext', 'body', 191, '__wp_mysql_index_type:RlVMTFRFWFQ=' ); + $assert_placeholder_sql( $inline_sql, $inline_table, 'inline_shape_spatial', 'shape', 32, '__wp_mysql_index_type:U1BBVElBTA==' ); + + foreach ( array( $standalone_table, $alter_table ) as $table_name ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `body` text, + `shape` point + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'public CREATE TABLE metadata-only target', + $backend_sql + ); + } + + $this->assertSame( + 0, + $driver->query( sprintf( 'CREATE FULLTEXT INDEX `standalone_body_fulltext` ON `%s` (`body`)', $standalone_table ) ) + ); + $standalone_fulltext_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public CREATE FULLTEXT INDEX metadata-only placeholder', + $backend_sql + ); + $assert_placeholder_sql( $standalone_fulltext_sql, $standalone_table, 'standalone_body_fulltext', 'body', 191, '__wp_mysql_index_type:RlVMTFRFWFQ=' ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'CREATE SPATIAL INDEX `standalone_shape_spatial` ON `%s` (`shape`)', $standalone_table ) ) + ); + $standalone_spatial_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public CREATE SPATIAL INDEX metadata-only placeholder', + $backend_sql + ); + $assert_placeholder_sql( $standalone_spatial_sql, $standalone_table, 'standalone_shape_spatial', 'shape', 32, '__wp_mysql_index_type:U1BBVElBTA==' ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD FULLTEXT KEY `alter_body_fulltext` (`body`) COMMENT "Search docs", + ADD SPATIAL INDEX `alter_shape_spatial` (`shape`)', + $alter_table + ) + ) + ); + $alter_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public ALTER TABLE ADD metadata-only indexes', + $backend_sql + ); + $assert_placeholder_sql( $alter_sql, $alter_table, 'alter_body_fulltext', 'body', 191, '__wp_mysql_index_type:RlVMTFRFWFQ=' ); + $assert_placeholder_sql( $alter_sql, $alter_table, 'alter_shape_spatial', 'shape', 32, '__wp_mysql_index_type:U1BBVElBTA==' ); + $this->assertStringContainsString( 'Search docs', $alter_sql ); + + foreach ( + array( + $inline_table => array( 'inline_body_fulltext', 'inline_shape_spatial' ), + $standalone_table => array( 'standalone_body_fulltext', 'standalone_shape_spatial' ), + $alter_table => array( 'alter_body_fulltext', 'alter_shape_spatial' ), + ) as $table_name => $index_names + ) { + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after public metadata-only placeholder creation', + $backend_sql + ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $indexes, 'Key_name', $index_names[0] ), 'Index_type' ) ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $this->find_row_by_value( $indexes, 'Key_name', $index_names[1] ), 'Index_type' ) ); + } + + $table_names_sql = implode( + ', ', + array_map( + array( $driver->get_connection(), 'quote' ), + array( $inline_table, $standalone_table, $alter_table ) + ) + ); + $statistics_rows = $driver->query( + "SELECT table_name, index_name, index_type, sub_part, index_comment + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name IN ({$table_names_sql}) + ORDER BY table_name, index_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after public metadata-only placeholder creation', + $backend_sql + ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'inline_body_fulltext' ), 'INDEX_TYPE' ) ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'inline_shape_spatial' ), 'INDEX_TYPE' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'standalone_body_fulltext' ), 'INDEX_TYPE' ) ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'standalone_shape_spatial' ), 'INDEX_TYPE' ) ); + $alter_fulltext = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'alter_body_fulltext' ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $alter_fulltext, 'INDEX_TYPE' ) ); + $this->assertSame( 'Search docs', $this->get_row_value( $alter_fulltext, 'INDEX_COMMENT' ) ); + $this->assertSame( '32', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'alter_shape_spatial' ), 'SUB_PART' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task944' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task944' ) ); + } + } + + /** + * Tests real PostgreSQL unrecoverable index metadata fails. + */ + public function test_real_pgsql_unrecoverable_index_metadata_fails(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL unrecoverable index metadata test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1017' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql, array $params = array() ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task1017_' . $suffix . '_target'; + $table_literal = $driver->get_connection()->quote( $table ); + $failed_index = 'uniq_shape_auto_spatial'; + $failed_index_literal = $driver->get_connection()->quote( $failed_index ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `body` text, + `shape` point NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE unrecoverable index metadata target table', + $backend_sql + ); + + $alter_start = count( $logged_sql ); + try { + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD UNIQUE KEY `uniq_shape_auto_spatial` (`shape`)', + $table + ) + ); + $this->fail( 'Expected unrecoverable index metadata to fail closed.' ); + } catch ( PDOException $e ) { + $this->fail( 'Expected recoverability exception, got raw PDOException: ' . $e->getMessage() ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported PostgreSQL catalog metadata for index statement.', $e->getMessage() ); + $this->assertNotSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + $this->assertNotSame( 'Unsupported CREATE INDEX statement.', $e->getMessage() ); + } + + $alter_sql = implode( "\n", array_slice( $logged_sql, $alter_start ) ); + $this->assertStringNotContainsString( '__wp_mysql_index_type:', $alter_sql ); + $this->assertStringNotContainsString( 'COMMENT ON INDEX', $alter_sql ); + $this->assertStringNotContainsString( 'CREATE INDEX "' . $table . '__' . $failed_index . '"', $alter_sql ); + $this->assertStringNotContainsString( 'CREATE UNIQUE INDEX "' . $table . '__' . $failed_index . '"', $alter_sql ); + + $show_index_rows = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after unrecoverable index metadata failure', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $show_index_rows, 'Key_name', $failed_index ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, index_comment + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + AND index_name = {$failed_index_literal} + ORDER BY index_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after unrecoverable index metadata failure', + $backend_sql + ); + $this->assertSame( array(), $statistics_rows ); + + $stmt = $pdo->prepare( + "SELECT + idx.relname, + pg_catalog.obj_description(idx.oid, 'pg_class') AS index_comment + FROM pg_catalog.pg_index i + INNER JOIN pg_catalog.pg_class t + ON t.oid = i.indrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + WHERE n.nspname = 'public' + AND t.relname = ? + ORDER BY idx.relname" + ); + $stmt->execute( array( $table ) ); + $raw_catalog_index_rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $raw_catalog_index_rows ); + $this->assertNotContains( $table . '__' . $failed_index, $this->get_public_pgsql_index_names_for_table( $pdo, $table ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1017' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1017' ) ); + } + } + + /** + * Tests real PostgreSQL index-existence paths use catalogs. + */ + public function test_real_pgsql_index_existence_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL index catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task892' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table = 'task892_' . $suffix . '_indexes'; + $table_literal = $driver->get_connection()->quote( $table ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `value` varchar(255) NOT NULL, + `status` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE index-existence table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD INDEX `value_idx` (`value`(16)) COMMENT "Lookup", + ADD INDEX `plain_idx` (`value`), + ADD UNIQUE KEY `status_unique` (`status`)', + $table + ) + ) + ); + $add_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD INDEX and UNIQUE for index-existence paths', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table . '__value_idx" ON "' . $table . '" (SUBSTR(CAST("value" AS text), 1, 16))', $add_index_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "public"."' . $table . '__value_idx" IS', $add_index_sql ); + $this->assertStringContainsString( '__wp_mysql_index_sub_part:1:16', $add_index_sql ); + $this->assertStringContainsString( 'Lookup', $add_index_sql ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table . '__plain_idx" ON "' . $table . '" ("value")', $add_index_sql ); + $this->assertStringContainsString( 'CREATE UNIQUE INDEX "' . $table . '__status_unique" ON "' . $table . '" ("status")', $add_index_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` ADD INDEX `status_unique` (`status`)', $table ) ); + $this->fail( 'Expected duplicate ADD INDEX to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate key name 'status_unique'.", $e->getMessage() ); + } + $duplicate_add_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'duplicate ALTER TABLE ADD INDEX', + $backend_sql + ); + $this->assertStringNotContainsString( 'CREATE INDEX', $duplicate_add_sql ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` RENAME INDEX `value_idx` TO `value_lookup`', $table ) ) + ); + $rename_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE RENAME INDEX value_idx', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER INDEX "public"."' . $table . '__value_idx" RENAME TO "' . $table . '__value_lookup"', $rename_index_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` RENAME INDEX `missing_idx` TO `missing_lookup`', $table ) ); + $this->fail( 'Expected RENAME INDEX with a missing source to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + $missing_rename_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE RENAME INDEX missing source', + $backend_sql + ); + $this->assertStringNotContainsString( 'ALTER INDEX', $missing_rename_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` RENAME INDEX `value_lookup` TO `status_unique`', $table ) ); + $this->fail( 'Expected RENAME INDEX with a duplicate target to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + $duplicate_rename_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE RENAME INDEX duplicate target', + $backend_sql + ); + $this->assertStringNotContainsString( 'ALTER INDEX', $duplicate_rename_sql ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` DROP INDEX `plain_idx`', $table ) ) + ); + $drop_plain_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP INDEX plain_idx', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $table . '__plain_idx"', $drop_plain_index_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` DROP CONSTRAINT `value_lookup`', $table ) ); + $this->fail( 'Expected DROP CONSTRAINT on a non-unique index to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + $non_unique_drop_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP CONSTRAINT non-unique index', + $backend_sql + ); + $this->assertStringNotContainsString( 'DROP INDEX', $non_unique_drop_sql ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` DROP CONSTRAINT `status_unique`', $table ) ) + ); + $unique_drop_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP CONSTRAINT unique index', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $table . '__status_unique"', $unique_drop_sql ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after index-existence mutations', + $backend_sql + ); + $index_names = array(); + foreach ( $indexes as $index ) { + $index_names[] = (string) $this->get_row_value( $index, 'Key_name' ); + } + $this->assertContains( 'PRIMARY', $index_names ); + $this->assertContains( 'value_lookup', $index_names ); + $this->assertNotContains( 'value_idx', $index_names ); + $this->assertNotContains( 'plain_idx', $index_names ); + $this->assertNotContains( 'status_unique', $index_names ); + $value_lookup_index = $this->find_row_by_value( $indexes, 'Key_name', 'value_lookup' ); + $this->assertSame( 'value', $this->get_row_value( $value_lookup_index, 'Column_name' ) ); + $this->assertSame( '16', (string) $this->get_row_value( $value_lookup_index, 'Sub_part' ) ); + $this->assertSame( 'Lookup', $this->get_row_value( $value_lookup_index, 'Index_comment' ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, column_name, sub_part, index_comment, non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics after index-existence mutations', + $backend_sql + ); + $statistics_index_names = array(); + foreach ( $statistics_rows as $statistic ) { + $statistics_index_names[] = (string) $this->get_row_value( $statistic, 'INDEX_NAME' ); + } + $this->assertContains( 'PRIMARY', $statistics_index_names ); + $this->assertContains( 'value_lookup', $statistics_index_names ); + $this->assertNotContains( 'value_idx', $statistics_index_names ); + $this->assertNotContains( 'plain_idx', $statistics_index_names ); + $this->assertNotContains( 'status_unique', $statistics_index_names ); + $value_lookup_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'value_lookup' ); + $this->assertSame( 'value', $this->get_row_value( $value_lookup_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '16', (string) $this->get_row_value( $value_lookup_statistic, 'SUB_PART' ) ); + $this->assertSame( 'Lookup', $this->get_row_value( $value_lookup_statistic, 'INDEX_COMMENT' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $value_lookup_statistic, 'NON_UNIQUE' ) ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task892' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task892' ) ); + } + } + + /** + * Tests real PostgreSQL current-schema index DDL/readback uses selected schema. + */ + public function test_real_pgsql_current_schema_index_and_describe_paths_use_selected_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL current-schema index catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task994' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task994_' . $suffix; + $table_name = 'index_target'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $table_sql = $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $table_literal = $driver->get_connection()->quote( $table_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( + 'CREATE TABLE ' . $table_sql . ' ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + value varchar(64) NOT NULL, + body text + )' + ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $use_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'USE task994 non-public schema', + $backend_sql + ); + $this->assertCount( 1, $use_queries ); + $this->assertStringContainsString( 'FROM information_schema.schemata s', $use_queries[0]['sql'] ); + $this->assertSame( array( $schema_name ), $use_queries[0]['params'] ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX `value_idx` ON `' . $table_name . '` (`value`) COMMENT "Lookup"' ) + ); + $create_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema CREATE INDEX', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__value_idx" ON "' . $schema_name . '"."' . $table_name . '" ("value")', $create_index_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__value_idx" IS', $create_index_sql ); + $this->assertStringContainsString( 'Lookup', $create_index_sql ); + $this->assertStringNotContainsString( '"public"."' . $table_name . '"', $create_index_sql ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX `explicit_idx` ON `' . $schema_name . '`.`' . $table_name . '` (`value`) COMMENT "Explicit"' ) + ); + $explicit_create_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit same-schema CREATE INDEX', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__explicit_idx" ON "' . $schema_name . '"."' . $table_name . '" ("value")', $explicit_create_index_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__explicit_idx" IS', $explicit_create_index_sql ); + $this->assertStringContainsString( 'Explicit', $explicit_create_index_sql ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after current-schema CREATE INDEX', + $backend_sql + ); + $value_index = $this->find_row_by_value( $indexes, 'Key_name', 'value_idx' ); + $this->assertSame( $table_name, $this->get_row_value( $value_index, 'Table' ) ); + $this->assertSame( 'value', $this->get_row_value( $value_index, 'Column_name' ) ); + $this->assertSame( 'Lookup', $this->get_row_value( $value_index, 'Index_comment' ) ); + $explicit_index = $this->find_row_by_value( $indexes, 'Key_name', 'explicit_idx' ); + $this->assertSame( $table_name, $this->get_row_value( $explicit_index, 'Table' ) ); + $this->assertSame( 'value', $this->get_row_value( $explicit_index, 'Column_name' ) ); + $this->assertSame( 'Explicit', $this->get_row_value( $explicit_index, 'Index_comment' ) ); + + $statistics_rows = $driver->query( + "SELECT table_schema, table_name, index_name, column_name, index_comment + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + AND index_name IN ('value_idx', 'explicit_idx') + ORDER BY index_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics for current-schema indexes', + $backend_sql + ); + $this->assertCount( 2, $statistics_rows ); + foreach ( $statistics_rows as $statistics_row ) { + $this->assertSame( $schema_name, $this->get_row_value( $statistics_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $statistics_row, 'TABLE_NAME' ) ); + $this->assertSame( 'value', $this->get_row_value( $statistics_row, 'COLUMN_NAME' ) ); + } + $this->assertSame( 'Explicit', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'explicit_idx' ), 'INDEX_COMMENT' ) ); + $this->assertSame( 'Lookup', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'value_idx' ), 'INDEX_COMMENT' ) ); + + $describe_rows = $driver->query( 'DESCRIBE `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'DESCRIBE current-schema table', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $describe_rows, 'Field', 'id' ) ); + $value_column = $this->find_row_by_value( $describe_rows, 'Field', 'value' ); + $this->assertSame( 'varchar(64)', $this->get_row_value( $value_column, 'Type' ) ); + $this->assertNotNull( $this->find_row_by_value( $describe_rows, 'Field', 'body' ) ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX `value_idx` ON `' . $table_name . '`' ) ); + $drop_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema DROP INDEX', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "' . $schema_name . '"."' . $table_name . '__value_idx"', $drop_index_sql ); + $this->assertStringNotContainsString( '"public"."' . $table_name . '__value_idx"', $drop_index_sql ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX `explicit_idx` ON `' . $schema_name . '`.`' . $table_name . '`' ) ); + $explicit_drop_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit same-schema DROP INDEX', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "' . $schema_name . '"."' . $table_name . '__explicit_idx"', $explicit_drop_index_sql ); + + $indexes_after_drop = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after current-schema DROP INDEX', + $backend_sql + ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_drop, 'Key_name', 'value_idx' ) ); + $this->assertNull( $this->find_optional_row_by_value( $indexes_after_drop, 'Key_name', 'explicit_idx' ) ); + $this->assertNotNull( $this->find_row_by_value( $indexes_after_drop, 'Key_name', 'PRIMARY' ) ); + + foreach ( + array( + 'CREATE INDEX `blocked_idx` ON `pg_catalog`.`pg_class` (`relname`)' => 'Unsupported CREATE INDEX statement.', + 'DROP INDEX `blocked_idx` ON `pg_catalog`.`pg_class`' => 'Unsupported DROP INDEX statement.', + 'DESCRIBE `pg_catalog`.`pg_class`' => 'Unsupported DESCRIBE statement.', + ) as $query => $message + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected internal PostgreSQL schema DDL/readback to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task994' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task994' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task994' ) ); + } + } + + /** + * Tests real PostgreSQL schema identifiers and temporary drops use PostgreSQL syntax. + */ + public function test_real_pgsql_schema_identifier_and_temporary_drop_paths_use_postgresql_identifiers(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL schema identifier and temporary drop test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task1042' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1042' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task1042_' . $suffix; + $public_table = 'task1042_' . $suffix . '_public'; + $shadow_table = 'task1042_' . $suffix . '_shadow'; + $selected_table = 'selected_target'; + $schema_drop_table = 'drop_target'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $shadow_table_sql = WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $shadow_table ); + $schema_drop_table_sql = $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $schema_drop_table ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `value` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + )', + $public_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE public task1042 table', + $backend_sql + ); + + $this->assertSame( 0, $driver->query( 'CREATE INDEX `value_lookup` ON `public`.`' . $public_table . '` (`value`)' ) ); + $create_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit public CREATE INDEX', + $backend_sql + ); + $this->assertStringContainsString( + 'CREATE INDEX "' . $public_table . '__value_lookup" ON "public"."' . $public_table . '" ("value")', + $create_index_sql + ); + $this->assertContains( $public_table . '__value_lookup', $this->get_public_pgsql_index_names_for_table( $pdo, $public_table ) ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX `value_lookup` ON `public`.`' . $public_table . '`' ) ); + $drop_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit public DROP INDEX', + $backend_sql + ); + $this->assertStringContainsString( 'DROP INDEX "public"."' . $public_table . '__value_lookup"', $drop_index_sql ); + $this->assertNotContains( $public_table . '__value_lookup', $this->get_public_pgsql_index_names_for_table( $pdo, $public_table ) ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $use_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'USE task1042 non-public schema', + $backend_sql + ); + $this->assertCount( 1, $use_queries ); + $this->assertStringContainsString( 'FROM information_schema.schemata s', $use_queries[0]['sql'] ); + $this->assertSame( array( $schema_name ), $use_queries[0]['params'] ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `value` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + )', + $selected_table + ) + ) + ); + $selected_create_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'selected-schema CREATE TABLE', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE "' . $schema_name . '"."' . $selected_table . '"', $selected_create_sql ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `{$selected_table}` (`id`, `value`) VALUES (1, 'selected')" ) ); + $selected_insert_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'selected-schema unqualified INSERT', + $backend_sql + ); + $this->assertStringContainsString( 'INSERT INTO "' . $schema_name . '"."' . $selected_table . '"', $selected_insert_sql ); + + $selected_rows = $driver->query( 'SELECT `id`, `value` FROM `' . $selected_table . '`' ); + $selected_select_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'selected-schema unqualified SELECT', + $backend_sql + ); + $this->assertCount( 1, $selected_rows ); + $this->assertSame( 'selected', $this->get_row_value( $selected_rows[0], 'value' ) ); + $this->assertStringContainsString( 'FROM "' . $schema_name . '"."' . $selected_table . '"', $selected_select_sql ); + + $pdo->exec( + 'CREATE TABLE ' . $schema_drop_table_sql . ' ( + id integer NOT NULL + )' + ); + $this->assertSame( 0, $driver->query( 'DROP TABLE `' . $schema_name . '`.`' . $schema_drop_table . '`' ) ); + $schema_drop_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit non-public DROP TABLE', + $backend_sql + ); + $this->assertStringContainsString( 'DROP TABLE "' . $schema_name . '"."' . $schema_drop_table . '"', $schema_drop_sql ); + $this->assertSame( + '0', + (string) $pdo->query( 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ' . $pdo->quote( $schema_name ) . ' AND table_name = ' . $pdo->quote( $schema_drop_table ) )->fetchColumn() + ); + + $this->assertSame( 0, $driver->query( 'USE public' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE public before temporary shadow table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `value` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + )', + $shadow_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE permanent shadow table', + $backend_sql + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `{$shadow_table}` (`id`, `value`) VALUES (1, 'permanent')" ) ); + $this->collect_last_postgresql_queries( + $driver, + 'seed permanent shadow table', + $backend_sql + ); + $this->assertSame( array( 'permanent' ), $pdo->query( 'SELECT value FROM ' . $shadow_table_sql . ' ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ) ); + + $this->assertSame( 0, $driver->query( 'CREATE TEMPORARY TABLE `' . $shadow_table . '` (`id` int NOT NULL, `value` varchar(20) NOT NULL)' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE temporary shadow table', + $backend_sql + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `{$shadow_table}` (`id`, `value`) VALUES (2, 'temporary')" ) ); + $this->collect_last_postgresql_queries( + $driver, + 'seed temporary shadow table', + $backend_sql + ); + + $temporary_rows = $driver->query( 'SELECT `value` FROM `' . $shadow_table . '` ORDER BY `id`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read temporary shadow table before DROP TEMPORARY', + $backend_sql + ); + $this->assertCount( 1, $temporary_rows ); + $this->assertSame( 'temporary', $this->get_row_value( $temporary_rows[0], 'value' ) ); + + $this->assertSame( 0, $driver->query( 'DROP TEMPORARY TABLE `' . $shadow_table . '`' ) ); + $drop_temporary_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP TEMPORARY TABLE with active temp table', + $backend_sql + ); + $this->assertStringContainsString( 'DROP TABLE pg_temp."' . $shadow_table . '"', $drop_temporary_sql ); + $this->assertSame( array( 'permanent' ), $pdo->query( 'SELECT value FROM ' . $shadow_table_sql . ' ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ) ); + + try { + $driver->query( 'DROP TEMPORARY TABLE `' . $shadow_table . '`' ); + $this->fail( 'DROP TEMPORARY TABLE without an active temp table should fail.' ); + } catch ( PDOException $e ) { + $this->assertNotSame( '', $e->getMessage() ); + } + $this->assertSame( array( 'permanent' ), $pdo->query( 'SELECT value FROM ' . $shadow_table_sql . ' ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ) ); + + $this->assertSame( 0, $driver->query( 'DROP TEMPORARY TABLE IF EXISTS `' . $shadow_table . '`' ) ); + $drop_temporary_if_exists_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'DROP TEMPORARY TABLE IF EXISTS without active temp table', + $backend_sql + ); + $this->assertStringContainsString( 'DROP TABLE IF EXISTS pg_temp."' . $shadow_table . '"', $drop_temporary_if_exists_sql ); + $this->assertSame( array( 'permanent' ), $pdo->query( 'SELECT value FROM ' . $shadow_table_sql . ' ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ) ); + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'DROP TABLE IF EXISTS pg_temp.' . WP_PostgreSQL_Connection::quote_identifier_value( $shadow_table ) ); + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task1042' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1042' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task1042' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task1042' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1042' ) ); + } + } + + /** + * Tests public table reads skip repeated per-table temporary schema probes. + */ + public function test_real_pgsql_public_table_resolution_skips_per_table_temporary_probe(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL public table schema cache test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1042_cache' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task1042_cache_' . $suffix; + $dummy_name = 'task1042_cache_dummy_' . $suffix; + $logged_sql = array(); + + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `value` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + )', + $table_name + ) + ) + ); + $this->assertSame( 1, $driver->query( "INSERT INTO `{$table_name}` (`id`, `value`) VALUES (1, 'public')" ) ); + + /* + * DDL clears metadata caches. The following read should still avoid + * the expensive per-table temporary schema lookup when no temporary + * table exists on the connection. + */ + $this->assertSame( 0, $driver->query( "CREATE TABLE `{$dummy_name}` (`id` int NOT NULL)" ) ); + $this->assertSame( 0, $driver->query( "DROP TABLE `{$dummy_name}`" ) ); + + $logged_sql = array(); + $rows = $driver->query( 'SELECT `value` FROM `' . $table_name . '` WHERE `id` = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'public', $this->get_row_value( $rows[0], 'value' ) ); + $this->assertStringNotContainsString( + 'SELECT n.nspname', + implode( "\n", $logged_sql ) + ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1042_cache' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1042_cache' ) ); + } + } + + /** + * Tests the direct column metadata API returns SHOW COLUMNS-compatible rows. + */ + public function test_real_pgsql_column_charset_metadata_api_matches_show_columns_and_resolves_temporary_tables(): void { + $driver = $this->create_real_pgsql_driver(); + $pdo = $driver->get_connection()->get_pdo(); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task_direct_meta_' . $suffix; + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task_direct_meta' ); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `body` text CHARACTER SET big5, + `views` int NOT NULL + )', + $table_name + ) + ) + ); + + $metadata_rows = $driver->get_mysql_column_charset_metadata_for_table( $table_name ); + $show_rows = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table_name . '`', PDO::FETCH_ASSOC ); + + $this->assertSame( + array_map( + static function ( array $row ): array { + return array( + 'column_name' => $row['Field'], + 'column_type' => $row['Type'], + 'collation_name' => $row['Collation'], + ); + }, + $show_rows + ), + $metadata_rows + ); + + $title_metadata = $this->find_row_by_value( $metadata_rows, 'column_name', 'title' ); + $this->assertSame( 'varchar(64)', $this->get_row_value( $title_metadata, 'column_type' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $title_metadata, 'collation_name' ) ); + $views_metadata = $this->find_row_by_value( $metadata_rows, 'column_name', 'views' ); + $this->assertNull( $this->get_row_value( $views_metadata, 'collation_name' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TEMPORARY TABLE `%s` ( + `title` text CHARACTER SET big5, + `views` int NOT NULL + )', + $table_name + ) + ) + ); + + $temporary_metadata_rows = $driver->get_mysql_column_charset_metadata_for_table( $table_name ); + $temporary_show_rows = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table_name . '`', PDO::FETCH_ASSOC ); + + $this->assertSame( array( 'title', 'views' ), array_column( $temporary_metadata_rows, 'column_name' ) ); + $temp_title_metadata = $this->find_row_by_value( $temporary_metadata_rows, 'column_name', 'title' ); + $this->assertSame( 'text', $this->get_row_value( $temp_title_metadata, 'column_type' ) ); + $this->assertSame( 'big5_chinese_ci', $this->get_row_value( $temp_title_metadata, 'collation_name' ) ); + $this->assertSame( + array_map( + static function ( array $row ): array { + return array( + 'column_name' => $row['Field'], + 'column_type' => $row['Type'], + 'collation_name' => $row['Collation'], + ); + }, + $temporary_show_rows + ), + $temporary_metadata_rows + ); + } finally { + $pdo->exec( 'DROP TABLE IF EXISTS pg_temp.' . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ) ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task_direct_meta' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task_direct_meta' ) ); + } + } + + /** + * Tests real PostgreSQL current-schema CREATE TABLE and VIEW DDL use selected schema. + */ + public function test_real_pgsql_current_schema_create_table_and_view_ddl_use_selected_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL current-schema CREATE TABLE and VIEW DDL test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task1003' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1003' ); + $this->drop_public_pgsql_views_with_prefix( $pdo, 'task1003' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task1003_' . $suffix; + $table_name = 'task1003_' . $suffix . '_table'; + $view_name = 'task1003_' . $suffix . '_view'; + $explicit_view_name = 'task1003_' . $suffix . '_explicit_view'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $use_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'USE task1003 non-public schema', + $backend_sql + ); + $this->assertCount( 1, $use_queries ); + $this->assertStringContainsString( 'FROM information_schema.schemata s', $use_queries[0]['sql'] ); + $this->assertSame( array( $schema_name ), $use_queries[0]['params'] ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` int NOT NULL, + `slug` varchar(191) NOT NULL DEFAULT 'draft' COMMENT 'Slug note', + `title` varchar(191) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_lookup` (`slug`) COMMENT 'Slug lookup note' + ) COMMENT='Catalog table note'", + $table_name + ) + ) + ); + $create_table_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema CREATE TABLE with comments and unique index', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE "' . $schema_name . '"."' . $table_name . '"', $create_table_sql ); + $this->assertStringContainsString( 'CREATE UNIQUE INDEX "' . $table_name . '__slug_lookup" ON "' . $schema_name . '"."' . $table_name . '" ("slug")', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT ON TABLE "' . $schema_name . '"."' . $table_name . '" IS', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT ON COLUMN "' . $schema_name . '"."' . $table_name . '"."slug" IS', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__slug_lookup" IS', $create_table_sql ); + $this->assertStringContainsString( 'Catalog table note', $create_table_sql ); + $this->assertStringContainsString( 'Slug note', $create_table_sql ); + $this->assertStringContainsString( 'Slug lookup note', $create_table_sql ); + $this->assertStringNotContainsString( '"public"."' . $table_name . '"', $create_table_sql ); + + $catalog_stmt = $pdo->prepare( + "SELECT + n.nspname, + c.relname, + pg_catalog.obj_description(c.oid, 'pg_class') AS table_comment, + pg_catalog.col_description(c.oid, a.attnum) AS slug_comment, + pg_catalog.obj_description(idx.oid, 'pg_class') AS index_comment, + idx.relname AS index_name + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + INNER JOIN pg_catalog.pg_attribute a + ON a.attrelid = c.oid + AND a.attname = 'slug' + INNER JOIN pg_catalog.pg_index i + ON i.indrelid = c.oid + AND i.indisunique + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + AND idx.relname = ? + WHERE n.nspname = ? + AND c.relname = ? + AND c.relkind IN ('r', 'p')" + ); + $catalog_stmt->execute( array( $table_name . '__slug_lookup', $schema_name, $table_name ) ); + $catalog_row = $catalog_stmt->fetch( PDO::FETCH_ASSOC ); + $this->assertIsArray( $catalog_row ); + $this->assertSame( $schema_name, $catalog_row['nspname'] ); + $this->assertSame( $table_name, $catalog_row['relname'] ); + $this->assertSame( 'Catalog table note', $catalog_row['table_comment'] ); + $this->assertSame( 'Slug note', $catalog_row['slug_comment'] ); + $this->assertSame( 'Slug lookup note', $catalog_row['index_comment'] ); + $this->assertSame( $table_name . '__slug_lookup', $catalog_row['index_name'] ); + + $public_table_count = (int) $pdo->query( + 'SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ' . $pdo->quote( 'public' ) . ' + AND table_name = ' . $pdo->quote( $table_name ) + )->fetchColumn(); + $this->assertSame( 0, $public_table_count ); + + $show_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE after current-schema CREATE TABLE', + $backend_sql + ); + $this->assertCount( 1, $show_create_rows ); + $show_create_sql = (string) $this->get_row_value( $show_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table_name . '`', $show_create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_lookup` (`slug`)', $show_create_sql ); + $this->assertStringContainsString( 'COMMENT=\'Catalog table note\'', $show_create_sql ); + $this->assertStringContainsString( 'COMMENT \'Slug note\'', $show_create_sql ); + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX after current-schema CREATE TABLE', + $backend_sql + ); + $slug_index = $this->find_row_by_value( $indexes, 'Key_name', 'slug_lookup' ); + $this->assertSame( $table_name, $this->get_row_value( $slug_index, 'Table' ) ); + $this->assertSame( 'slug', $this->get_row_value( $slug_index, 'Column_name' ) ); + $this->assertSame( 'Slug lookup note', $this->get_row_value( $slug_index, 'Index_comment' ) ); + + $this->assertSame( + 0, + $driver->query( 'CREATE VIEW `' . $view_name . '` AS SELECT `id`, `slug` FROM `' . $table_name . '`' ) + ); + $create_view_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema CREATE VIEW', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE VIEW "' . $schema_name . '"."' . $view_name . '" AS SELECT', $create_view_sql ); + $this->assertStringContainsString( 'FROM "' . $schema_name . '"."' . $table_name . '"', $create_view_sql ); + $this->assertStringNotContainsString( '"public"."' . $view_name . '"', $create_view_sql ); + + $this->assertSame( + 0, + $driver->query( 'CREATE OR REPLACE VIEW `' . $schema_name . '`.`' . $explicit_view_name . '` AS SELECT `id` FROM `' . $schema_name . '`.`' . $table_name . '`' ) + ); + $explicit_create_view_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit same-schema CREATE OR REPLACE VIEW', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE OR REPLACE VIEW "' . $schema_name . '"."' . $explicit_view_name . '" AS SELECT', $explicit_create_view_sql ); + $this->assertStringContainsString( 'FROM "' . $schema_name . '"."' . $table_name . '"', $explicit_create_view_sql ); + + $view_stmt = $pdo->prepare( + 'SELECT table_name + FROM information_schema.views + WHERE table_schema = ? + AND table_name IN (?, ?) + ORDER BY table_name' + ); + $view_stmt->execute( array( $schema_name, $explicit_view_name, $view_name ) ); + $this->assertSame( array( $explicit_view_name, $view_name ), $view_stmt->fetchAll( PDO::FETCH_COLUMN ) ); + + $public_view_stmt = $pdo->prepare( + 'SELECT table_name + FROM information_schema.views + WHERE table_schema = ? + AND table_name IN (?, ?) + ORDER BY table_name' + ); + $public_view_stmt->execute( array( 'public', $explicit_view_name, $view_name ) ); + $this->assertSame( array(), $public_view_stmt->fetchAll( PDO::FETCH_COLUMN ) ); + + $this->assertSame( + 0, + $driver->query( 'DROP VIEW IF EXISTS `' . $view_name . '`, `' . $schema_name . '`.`' . $explicit_view_name . '` RESTRICT' ) + ); + $drop_view_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema and explicit DROP VIEW', + $backend_sql + ); + $this->assertStringContainsString( 'DROP VIEW IF EXISTS "' . $schema_name . '"."' . $view_name . '"', $drop_view_sql ); + $this->assertStringContainsString( 'DROP VIEW IF EXISTS "' . $schema_name . '"."' . $explicit_view_name . '"', $drop_view_sql ); + $this->assertStringNotContainsString( '"public"."' . $view_name . '"', $drop_view_sql ); + + $view_stmt->execute( array( $schema_name, $explicit_view_name, $view_name ) ); + $this->assertSame( array(), $view_stmt->fetchAll( PDO::FETCH_COLUMN ) ); + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task1003' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1003' ); + $this->drop_public_pgsql_views_with_prefix( $pdo, 'task1003' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task1003' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task1003' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1003' ) ); + $this->assertSame( array(), $this->get_public_pgsql_views_with_prefix( $pdo, 'task1003' ) ); + } + } + + /** + * Tests real PostgreSQL foreign-key existence paths use catalogs. + */ + public function test_real_pgsql_foreign_key_existence_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL foreign-key catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task896' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $parent_table = 'task896_' . $suffix . '_parent'; + $child_table = 'task896_' . $suffix . '_child'; + $child_table_literal = $driver->get_connection()->quote( $child_table ); + $named_fk = 'fk_catalog_parent'; + $inline_generated_fk = $child_table . '_ibfk_1'; + $generated_fk_one = $child_table . '_ibfk_2'; + $generated_fk_two = $child_table . '_ibfk_3'; + $expected_constraints = array( $generated_fk_one, $generated_fk_two, $inline_generated_fk, $named_fk ); + $quoted_constraints = implode( + ', ', + array_map( + array( $driver->get_connection(), 'quote' ), + $expected_constraints + ) + ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $parent_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE foreign-key parent table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `parent_id` int DEFAULT NULL, + `alt_parent_id` int DEFAULT NULL, + `third_parent_id` int DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $child_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE foreign-key child table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD CONSTRAINT `%s` FOREIGN KEY (`parent_id`) + REFERENCES `%s` (`id`) + ON DELETE CASCADE ON UPDATE SET NULL', + $child_table, + $named_fk, + $parent_table + ) + ) + ); + $add_named_fk_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD named foreign key', + $backend_sql + ); + $this->assertStringContainsString( + 'ALTER TABLE "' . $child_table . '" ADD CONSTRAINT "' . $named_fk . '" FOREIGN KEY ("parent_id") REFERENCES "' . $parent_table . '" ("id") ON DELETE CASCADE ON UPDATE SET NULL', + $add_named_fk_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD COLUMN `inline_parent_id` int REFERENCES `%s` (`id`) + ON DELETE CASCADE ON UPDATE SET NULL', + $child_table, + $parent_table + ) + ) + ); + $add_inline_fk_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD COLUMN inline foreign key', + $backend_sql + ); + $this->assertStringContainsString( + 'ALTER TABLE "' . $child_table . '" ADD COLUMN "inline_parent_id" integer CONSTRAINT "' . $inline_generated_fk . '" REFERENCES "' . $parent_table . '" ("id") ON DELETE CASCADE ON UPDATE SET NULL', + $add_inline_fk_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD FOREIGN KEY (`alt_parent_id`) + REFERENCES `%s` (`id`)', + $child_table, + $parent_table + ) + ) + ); + $add_generated_fk_one_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD first generated foreign key', + $backend_sql + ); + $this->assertStringContainsString( + 'ALTER TABLE "' . $child_table . '" ADD CONSTRAINT "' . $generated_fk_one . '" FOREIGN KEY ("alt_parent_id") REFERENCES "' . $parent_table . '" ("id")', + $add_generated_fk_one_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD FOREIGN KEY (`third_parent_id`) + REFERENCES `%s` (`id`)', + $child_table, + $parent_table + ) + ) + ); + $add_generated_fk_two_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE ADD second generated foreign key', + $backend_sql + ); + $this->assertStringContainsString( + 'ALTER TABLE "' . $child_table . '" ADD CONSTRAINT "' . $generated_fk_two . '" FOREIGN KEY ("third_parent_id") REFERENCES "' . $parent_table . '" ("id")', + $add_generated_fk_two_sql + ); + + $referential_rows = $driver->query( + "SELECT constraint_name, update_rule, delete_rule, table_name, referenced_table_name + FROM information_schema.referential_constraints + WHERE constraint_schema = DATABASE() + AND table_name = {$child_table_literal} + AND constraint_name IN ({$quoted_constraints}) + ORDER BY constraint_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.referential_constraints after ADD FOREIGN KEY', + $backend_sql + ); + $this->assertCount( 4, $referential_rows ); + $named_row = $this->find_row_by_value( $referential_rows, 'CONSTRAINT_NAME', $named_fk ); + $this->assertSame( $child_table, $this->get_row_value( $named_row, 'TABLE_NAME' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $named_row, 'REFERENCED_TABLE_NAME' ) ); + $this->assertSame( 'SET NULL', $this->get_row_value( $named_row, 'UPDATE_RULE' ) ); + $this->assertSame( 'CASCADE', $this->get_row_value( $named_row, 'DELETE_RULE' ) ); + $this->find_row_by_value( $referential_rows, 'CONSTRAINT_NAME', $inline_generated_fk ); + $this->find_row_by_value( $referential_rows, 'CONSTRAINT_NAME', $generated_fk_one ); + $this->find_row_by_value( $referential_rows, 'CONSTRAINT_NAME', $generated_fk_two ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` DROP FOREIGN KEY `%s`', $child_table, $generated_fk_one ) ) + ); + $drop_generated_fk_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP FOREIGN KEY generated constraint', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER TABLE "' . $child_table . '" DROP CONSTRAINT "' . $generated_fk_one . '"', $drop_generated_fk_sql ); + + $this->assertSame( + 0, + $driver->query( sprintf( 'ALTER TABLE `%s` DROP CONSTRAINT `%s`', $child_table, $named_fk ) ) + ); + $drop_named_fk_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP CONSTRAINT named foreign key', + $backend_sql + ); + $this->assertStringContainsString( 'ALTER TABLE "' . $child_table . '" DROP CONSTRAINT "' . $named_fk . '"', $drop_named_fk_sql ); + + try { + $driver->query( sprintf( 'ALTER TABLE `%s` DROP FOREIGN KEY `missing_fk`', $child_table ) ); + $this->fail( 'Expected DROP FOREIGN KEY with missing PostgreSQL constraint to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + $missing_drop_fk_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER TABLE DROP FOREIGN KEY missing constraint', + $backend_sql + ); + $this->assertStringNotContainsString( 'DROP CONSTRAINT', $missing_drop_fk_sql ); + + $key_column_rows = $driver->query( + "SELECT constraint_name, column_name, referenced_table_name, referenced_column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = {$child_table_literal} + AND referenced_table_name IS NOT NULL + ORDER BY constraint_name, ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.key_column_usage after DROP FOREIGN KEY', + $backend_sql + ); + $this->assertCount( 2, $key_column_rows ); + $inline_key_column = $this->find_row_by_value( $key_column_rows, 'CONSTRAINT_NAME', $inline_generated_fk ); + $this->assertSame( 'inline_parent_id', $this->get_row_value( $inline_key_column, 'COLUMN_NAME' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $inline_key_column, 'REFERENCED_TABLE_NAME' ) ); + $this->assertSame( 'id', $this->get_row_value( $inline_key_column, 'REFERENCED_COLUMN_NAME' ) ); + $generated_key_column = $this->find_row_by_value( $key_column_rows, 'CONSTRAINT_NAME', $generated_fk_two ); + $this->assertSame( 'third_parent_id', $this->get_row_value( $generated_key_column, 'COLUMN_NAME' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $generated_key_column, 'REFERENCED_TABLE_NAME' ) ); + $this->assertSame( 'id', $this->get_row_value( $generated_key_column, 'REFERENCED_COLUMN_NAME' ) ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task896' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task896' ) ); + } + } + + /** + * Tests non-public PostgreSQL catalog roundtrips for metadata-only index types. + */ + public function test_real_pgsql_schema_qualified_metadata_only_catalog_roundtrip_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL schema metadata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task819' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task819' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task819_' . $suffix; + $table_name = 'catalog_roundtrip'; + $view_name = 'catalog_roundtrip_view'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $table_literal = $driver->get_connection()->quote( $table_name ); + $schema_literal = $driver->get_connection()->quote( $schema_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE non-public schema', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s`.`%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `body` text, + `shape` point, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + FULLTEXT KEY `body_fulltext` (`body`), + SPATIAL KEY `shape_spatial` (`shape`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 819 table'", + $schema_name, + $table_name + ) + ) + ); + $create_table_backend_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__body_fulltext"', $create_table_backend_sql ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__shape_spatial"', $create_table_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__body_fulltext"', $create_table_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__shape_spatial"', $create_table_backend_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified CREATE TABLE with metadata-only indexes', + $backend_sql + ); + + $pdo->exec( + 'CREATE VIEW ' + . $schema_sql + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $view_name ) + . ' AS SELECT id, slug FROM ' + . $schema_sql + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ) + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE FULLTEXT INDEX `body_fulltext_created` ON `%s`.`%s` (`body`)', + $schema_name, + $table_name + ) + ) + ); + $create_fulltext_backend_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__body_fulltext_created"', $create_fulltext_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__body_fulltext_created"', $create_fulltext_backend_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified CREATE FULLTEXT INDEX', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE SPATIAL INDEX `shape_spatial_created` ON `%s` (`shape`)', + $table_name + ) + ) + ); + $create_spatial_backend_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__shape_spatial_created"', $create_spatial_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__shape_spatial_created"', $create_spatial_backend_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema CREATE SPATIAL INDEX', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` + ADD FULLTEXT KEY `body_fulltext_alter` (`body`) COMMENT "Search docs", + ADD SPATIAL INDEX `shape_spatial_alter` (`shape`)', + $table_name + ) + ) + ); + $alter_backend_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__body_fulltext_alter"', $alter_backend_sql ); + $this->assertStringContainsString( 'CREATE INDEX "' . $table_name . '__shape_spatial_alter"', $alter_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__body_fulltext_alter"', $alter_backend_sql ); + $this->assertStringContainsString( 'COMMENT ON INDEX "' . $schema_name . '"."' . $table_name . '__shape_spatial_alter"', $alter_backend_sql ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema ALTER TABLE ADD metadata-only indexes', + $backend_sql + ); + + $show_tables = $driver->query( 'SHOW TABLES LIKE ' . $table_literal ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLES LIKE non-public table', + $backend_sql + ); + $this->assertCount( 1, $show_tables ); + $this->assertContains( $table_name, array_values( get_object_vars( $show_tables[0] ) ) ); + + $non_public_table_column = 'Tables_in_' . $schema_name; + $non_public_full_tables = $driver->query( 'SHOW FULL TABLES FROM `' . $schema_name . '`' ); + $show_full_queries = $driver->get_last_postgresql_queries(); + $show_full_backend_sql = implode( "\n", array_column( $show_full_queries, 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL TABLES FROM non-public schema', + $backend_sql + ); + $this->assertSame( + array( $non_public_table_column, 'Table_type' ), + array_keys( get_object_vars( $non_public_full_tables[0] ) ) + ); + $non_public_table_types = array(); + foreach ( $non_public_full_tables as $non_public_full_table ) { + $non_public_table_types[ $this->get_row_value( $non_public_full_table, $non_public_table_column ) ] = $this->get_row_value( $non_public_full_table, 'Table_type' ); + } + $this->assertSame( 'BASE TABLE', $non_public_table_types[ $table_name ] ?? null ); + $this->assertSame( 'VIEW', $non_public_table_types[ $view_name ] ?? null ); + $this->assertCount( 1, $show_full_queries ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $show_full_backend_sql ); + $this->assertStringNotContainsString( 'SHOW TABLES', $show_full_backend_sql ); + $this->assertSame( array( $schema_name ), $show_full_queries[0]['params'] ); + + $qualified_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema_name . '`.`' . $table_name . '`' ); + $qualified_create_queries = $driver->get_last_postgresql_queries(); + $qualified_create_sql = implode( "\n", array_column( $qualified_create_queries, 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified SHOW CREATE TABLE non-public table', + $backend_sql + ); + $this->assertCount( 1, $qualified_create_rows ); + $this->assertSame( $table_name, $this->get_row_value( $qualified_create_rows[0], 'Table' ) ); + $qualified_create_table_sql = (string) $this->get_row_value( $qualified_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table_name . '`', $qualified_create_table_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $qualified_create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext` (`body`)', $qualified_create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial` (`shape`(32))', $qualified_create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext_created` (`body`)', $qualified_create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial_created` (`shape`(32))', $qualified_create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext_alter` (`body`) COMMENT \'Search docs\'', $qualified_create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial_alter` (`shape`(32))', $qualified_create_table_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 819 table\'', $qualified_create_table_sql ); + $this->assertCount( 5, $qualified_create_queries ); + foreach ( $qualified_create_queries as $qualified_create_query ) { + $this->assertContains( $schema_name, $qualified_create_query['params'] ); + $this->assertContains( $table_name, $qualified_create_query['params'] ); + } + $this->assertStringContainsString( 'FROM information_schema.columns c', $qualified_create_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $qualified_create_sql ); + $this->assertStringContainsString( 'information_schema.key_column_usage', $qualified_create_sql ); + $this->assertStringContainsString( 'information_schema.referential_constraints', $qualified_create_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_constraint', $qualified_create_sql ); + $this->assertStringContainsString( 'AS "TABLE_COMMENT"', $qualified_create_sql ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE non-public table', + $backend_sql + ); + $this->assertCount( 1, $create_rows ); + $create_table_sql = (string) $this->get_row_value( $create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $table_name . '`', $create_table_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext` (`body`)', $create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial` (`shape`(32))', $create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext_created` (`body`)', $create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial_created` (`shape`(32))', $create_table_sql ); + $this->assertStringContainsString( 'FULLTEXT KEY `body_fulltext_alter` (`body`) COMMENT \'Search docs\'', $create_table_sql ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial_alter` (`shape`(32))', $create_table_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 819 table\'', $create_table_sql ); + + try { + $driver->query( 'SHOW CREATE TABLE pg_catalog.pg_class' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW CREATE TABLE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW CREATE TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $qualified_columns = $driver->query( 'SHOW COLUMNS FROM `' . $schema_name . '`.`' . $table_name . '`' ); + $qualified_columns_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified SHOW COLUMNS non-public table', + $backend_sql + ); + $this->assertCount( 4, $qualified_columns ); + $this->assertSame( array( $schema_name, $table_name ), $qualified_columns_queries[0]['params'] ); + $this->assertStringContainsString( 'information_schema.columns c', $qualified_columns_queries[0]['sql'] ); + $qualified_id_column = $this->find_row_by_value( $qualified_columns, 'Field', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $qualified_id_column, 'Type' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $qualified_id_column, 'Key' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $qualified_id_column, 'Extra' ) ); + + try { + $driver->query( 'SHOW COLUMNS FROM pg_catalog.pg_class' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW COLUMNS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $columns = $driver->query( 'SHOW COLUMNS FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS non-public table', + $backend_sql + ); + $this->assertCount( 4, $columns ); + $id_column = $this->find_row_by_value( $columns, 'Field', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_column, 'Type' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_column, 'Key' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'Extra' ) ); + $this->assertSame( 'UNI', $this->get_row_value( $this->find_row_by_value( $columns, 'Field', 'slug' ), 'Key' ) ); + $this->assertSame( 'MUL', $this->get_row_value( $this->find_row_by_value( $columns, 'Field', 'body' ), 'Key' ) ); + $this->assertSame( 'point', $this->get_row_value( $this->find_row_by_value( $columns, 'Field', 'shape' ), 'Type' ) ); + + $qualified_indexes = $driver->query( 'SHOW INDEX FROM `' . $schema_name . '`.`' . $table_name . '`' ); + $qualified_indexes_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified SHOW INDEX non-public table', + $backend_sql + ); + $this->assertGreaterThanOrEqual( 8, count( $qualified_indexes ) ); + $this->assertSame( array( $schema_name, $table_name ), $qualified_indexes_queries[0]['params'] ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $qualified_indexes_queries[0]['sql'] ); + $this->assertStringContainsString( 'AS show_index_rows', $qualified_indexes_queries[0]['sql'] ); + $qualified_primary_index = $this->find_row_by_value( $qualified_indexes, 'Key_name', 'PRIMARY' ); + $this->assertSame( $table_name, $this->get_row_value( $qualified_primary_index, 'Table' ) ); + $this->assertSame( 'PRIMARY', $this->get_row_value( $qualified_primary_index, 'Key_name' ) ); + $this->assertSame( 'id', $this->get_row_value( $qualified_primary_index, 'Column_name' ) ); + + try { + $driver->query( 'SHOW INDEX FROM pg_catalog.pg_class' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW INDEX statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW INDEX statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $indexes = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX non-public table', + $backend_sql + ); + $this->assertGreaterThanOrEqual( 8, count( $indexes ) ); + $this->assertSame( 'BTREE', $this->get_row_value( $this->find_row_by_value( $indexes, 'Key_name', 'PRIMARY' ), 'Index_type' ) ); + $this->assertSame( 'BTREE', $this->get_row_value( $this->find_row_by_value( $indexes, 'Key_name', 'slug_unique' ), 'Index_type' ) ); + $fulltext_index = $this->find_row_by_value( $indexes, 'Key_name', 'body_fulltext_alter' ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $fulltext_index, 'Index_type' ) ); + $this->assertNull( $this->get_row_value( $fulltext_index, 'Sub_part' ) ); + $this->assertSame( 'Search docs', $this->get_row_value( $fulltext_index, 'Index_comment' ) ); + $spatial_index = $this->find_row_by_value( $indexes, 'Key_name', 'shape_spatial_alter' ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $spatial_index, 'Index_type' ) ); + $this->assertSame( '32', $this->get_row_value( $spatial_index, 'Sub_part' ) ); + + $unfiltered_table_status_rows = $driver->query( 'SHOW TABLE STATUS FROM `' . $schema_name . '`' ); + $unfiltered_table_status_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'unfiltered schema-qualified SHOW TABLE STATUS non-public table', + $backend_sql + ); + $this->assertCount( 1, $unfiltered_table_status_rows ); + $this->assertSame( $table_name, $this->get_row_value( $unfiltered_table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $unfiltered_table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $unfiltered_table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Task 819 table', $this->get_row_value( $unfiltered_table_status_rows[0], 'Comment' ) ); + $this->assertSame( array( $schema_name, 'BASE TABLE' ), $unfiltered_table_status_queries[0]['params'] ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $unfiltered_table_status_queries[0]['sql'] ); + $this->assertStringContainsString( 'pg_catalog.obj_description(pc.oid, \'pg_class\')', $unfiltered_table_status_queries[0]['sql'] ); + + $qualified_table_status_rows = $driver->query( 'SHOW TABLE STATUS FROM `' . $schema_name . '` LIKE ' . $table_literal ); + $qualified_table_status_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'schema-qualified SHOW TABLE STATUS non-public table', + $backend_sql + ); + $this->assertCount( 1, $qualified_table_status_rows ); + $this->assertSame( $table_name, $this->get_row_value( $qualified_table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $qualified_table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $qualified_table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Task 819 table', $this->get_row_value( $qualified_table_status_rows[0], 'Comment' ) ); + $this->assertSame( array( $schema_name, 'BASE TABLE' ), $qualified_table_status_queries[0]['params'] ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $qualified_table_status_queries[0]['sql'] ); + $this->assertStringContainsString( 'pg_catalog.obj_description(pc.oid, \'pg_class\')', $qualified_table_status_queries[0]['sql'] ); + + try { + $driver->query( 'SHOW TABLE STATUS FROM pg_catalog' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW TABLE STATUS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLE STATUS statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $table_status_rows = $driver->query( 'SHOW TABLE STATUS LIKE ' . $table_literal ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TABLE STATUS non-public table', + $backend_sql + ); + $this->assertCount( 1, $table_status_rows ); + $this->assertSame( $table_name, $this->get_row_value( $table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Task 819 table', $this->get_row_value( $table_status_rows[0], 'Comment' ) ); + + $fulltext_indexes = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Index_type = 'FULLTEXT'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX WHERE FULLTEXT', + $backend_sql + ); + $this->assertCount( 3, $fulltext_indexes ); + $this->assertNotNull( $this->find_row_by_value( $fulltext_indexes, 'Key_name', 'body_fulltext' ) ); + $this->assertNotNull( $this->find_row_by_value( $fulltext_indexes, 'Key_name', 'body_fulltext_created' ) ); + $this->assertNotNull( $this->find_row_by_value( $fulltext_indexes, 'Key_name', 'body_fulltext_alter' ) ); + + $spatial_indexes = $driver->query( 'SHOW INDEX FROM `' . $table_name . '` WHERE Sub_part = 32' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX WHERE SPATIAL sub_part', + $backend_sql + ); + $this->assertCount( 3, $spatial_indexes ); + $this->assertNotNull( $this->find_row_by_value( $spatial_indexes, 'Key_name', 'shape_spatial' ) ); + $this->assertNotNull( $this->find_row_by_value( $spatial_indexes, 'Key_name', 'shape_spatial_created' ) ); + $this->assertNotNull( $this->find_row_by_value( $spatial_indexes, 'Key_name', 'shape_spatial_alter' ) ); + + $table_rows = $driver->query( + "SELECT table_schema, table_name, table_comment + FROM information_schema.tables + WHERE table_schema = {$schema_literal} + AND table_name = {$table_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables non-public schema', + $backend_sql + ); + $this->assertCount( 1, $table_rows ); + $this->assertSame( $schema_name, $this->get_row_value( $table_rows[0], 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $table_rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'Task 819 table', $this->get_row_value( $table_rows[0], 'TABLE_COMMENT' ) ); + + $column_rows = $driver->query( + "SELECT table_schema, table_name, column_name, column_type, column_key, extra + FROM information_schema.columns + WHERE table_schema = {$schema_literal} + AND table_name = {$table_literal} + ORDER BY ordinal_position" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns non-public schema', + $backend_sql + ); + $this->assertCount( 4, $column_rows ); + foreach ( $column_rows as $column_row ) { + $this->assertSame( $schema_name, $this->get_row_value( $column_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $column_row, 'TABLE_NAME' ) ); + } + $id_information_schema_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_information_schema_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_information_schema_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_information_schema_column, 'EXTRA' ) ); + + $statistics_rows = $driver->query( + "SELECT table_schema, table_name, index_name, column_name, sub_part, index_type, index_comment + FROM information_schema.statistics + WHERE table_schema = {$schema_literal} + AND table_name = {$table_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics non-public schema', + $backend_sql + ); + $this->assertGreaterThanOrEqual( 8, count( $statistics_rows ) ); + foreach ( $statistics_rows as $statistics_row ) { + $this->assertSame( $schema_name, $this->get_row_value( $statistics_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $statistics_row, 'TABLE_NAME' ) ); + } + $this->assertSame( 'BTREE', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'PRIMARY' ), 'INDEX_TYPE' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'body_fulltext' ), 'INDEX_TYPE' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'body_fulltext_created' ), 'INDEX_TYPE' ) ); + $alter_fulltext_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'body_fulltext_alter' ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $alter_fulltext_statistic, 'INDEX_TYPE' ) ); + $this->assertSame( 'Search docs', $this->get_row_value( $alter_fulltext_statistic, 'INDEX_COMMENT' ) ); + $this->assertSame( 'SPATIAL', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'shape_spatial' ), 'INDEX_TYPE' ) ); + $this->assertSame( '32', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'shape_spatial_created' ), 'SUB_PART' ) ); + $this->assertSame( '32', $this->get_row_value( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'shape_spatial_alter' ), 'SUB_PART' ) ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task819' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task819' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task819' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task819' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task819' ) ); + } + } + + /** + * Tests real PostgreSQL columnless REPLACE metadata paths use catalogs. + */ + public function test_real_pgsql_columnless_replace_metadata_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL columnless REPLACE metadata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task841' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task841' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $public_values_target = 'task841_' . $suffix . '_values_target'; + $public_select_target = 'task841_' . $suffix . '_select_target'; + $public_select_source = 'task841_' . $suffix . '_select_source'; + $schema_name = 'task841_' . $suffix; + $schema_values_target = 'task841_' . $suffix . '_schema_values_target'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $backend_sql = array(); + $get_rows = static function ( PDO $pdo, string $schema, string $table ): array { + return $pdo->query( + 'SELECT pk, slug, value FROM ' + . WP_PostgreSQL_Connection::quote_identifier_value( $schema ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $table ) + . ' ORDER BY slug' + )->fetchAll( PDO::FETCH_ASSOC ); + }; + $expected_replace_rows = array( + array( + 'pk' => '3', + 'slug' => 'other', + 'value' => 'created', + ), + array( + 'pk' => '2', + 'slug' => 'same', + 'value' => 'new', + ), + ); + + try { + foreach ( array( $public_values_target, $public_select_target, $public_select_source ) as $table_name ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `pk` int NOT NULL, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + PRIMARY KEY (`pk`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE public REPLACE table', + $backend_sql + ); + } + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (1, 'same', 'old')", $public_values_target ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed public REPLACE VALUES target', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (1, 'same', 'old')", $public_select_target ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed public REPLACE SELECT target', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (2, 'same', 'new'), (3, 'other', 'created')", $public_select_source ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed public REPLACE SELECT source', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( sprintf( "REPLACE INTO `%s` VALUES (2, 'same', 'new'), (3, 'other', 'created')", $public_values_target ) ) + ); + $public_values_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public columnless REPLACE VALUES', + $backend_sql + ); + $this->assertStringContainsString( 'DELETE FROM "' . $public_values_target . '" WHERE', $public_values_sql ); + $this->assertStringContainsString( '"slug" = \'same\'', $public_values_sql ); + $this->assertStringContainsString( + 'INSERT INTO "' . $public_values_target . '" ("pk", "slug", "value") VALUES', + $public_values_sql + ); + $this->assertSame( $expected_replace_rows, $get_rows( $pdo, 'public', $public_values_target ) ); + + $this->assertSame( + 3, + $driver->query( sprintf( 'REPLACE INTO `%s` SELECT `pk`, `slug`, `value` FROM `%s`', $public_select_target, $public_select_source ) ) + ); + $public_select_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'public columnless REPLACE SELECT', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE "__wp_pg_replace_select_', $public_select_sql ); + $this->assertStringContainsString( 'DELETE FROM "' . $public_select_target . '" AS "__wp_pg_replace_target"', $public_select_sql ); + $this->assertStringContainsString( '"__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug"', $public_select_sql ); + $this->assertStringContainsString( + 'INSERT INTO "' . $public_select_target . '" ("pk", "slug", "value") SELECT "__wp_pg_replace_rows"."pk", "__wp_pg_replace_rows"."slug", "__wp_pg_replace_rows"."value"', + $public_select_sql + ); + $this->assertSame( $expected_replace_rows, $get_rows( $pdo, 'public', $public_select_target ) ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE current schema for columnless REPLACE', + $backend_sql + ); + + foreach ( array( $schema_values_target ) as $table_name ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `pk` int NOT NULL, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + PRIMARY KEY (`pk`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema REPLACE table', + $backend_sql + ); + } + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (1, 'same', 'old')", $schema_values_target ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema REPLACE VALUES target', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( sprintf( "REPLACE INTO `%s` VALUES (2, 'same', 'new'), (3, 'other', 'created')", $schema_values_target ) ) + ); + $schema_values_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema columnless REPLACE VALUES', + $backend_sql + ); + $this->assertStringContainsString( 'DELETE FROM "' . $schema_name . '"."' . $schema_values_target . '" WHERE', $schema_values_sql ); + $this->assertStringContainsString( + 'INSERT INTO "' . $schema_name . '"."' . $schema_values_target . '" ("pk", "slug", "value") VALUES', + $schema_values_sql + ); + $this->assertSame( $expected_replace_rows, $get_rows( $pdo, $schema_name, $schema_values_target ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task841' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task841' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task841' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task841' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task841' ) ); + } + } + + /** + * Tests real PostgreSQL current-schema REPLACE SELECT uses the selected schema. + */ + public function test_real_pgsql_current_schema_replace_select_uses_selected_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL current-schema REPLACE SELECT catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task858' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task858' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task858_' . $suffix; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $target_no_shadow = 'task858_' . $suffix . '_target_no_shadow'; + $source_no_shadow = 'task858_' . $suffix . '_source_no_shadow'; + $target_shadow = 'task858_' . $suffix . '_target_shadow'; + $source_shadow = 'task858_' . $suffix . '_source_shadow'; + $backend_sql = array(); + $get_rows = static function ( PDO $pdo, string $schema, string $table ): array { + return $pdo->query( + 'SELECT pk, slug, value FROM ' + . WP_PostgreSQL_Connection::quote_identifier_value( $schema ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $table ) + . ' ORDER BY slug' + )->fetchAll( PDO::FETCH_ASSOC ); + }; + $create_table = static function ( WP_PostgreSQL_Driver $driver, string $table_name ): int { + return $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `pk` int NOT NULL, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + PRIMARY KEY (`pk`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ); + }; + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE current schema for REPLACE SELECT source qualification', + $backend_sql + ); + + foreach ( array( $target_no_shadow, $source_no_shadow, $target_shadow, $source_shadow ) as $table_name ) { + $this->assertSame( 0, $create_table( $driver, $table_name ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema REPLACE SELECT table', + $backend_sql + ); + } + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (1, 'same', 'schema-old')", $target_no_shadow ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema no-shadow REPLACE SELECT target', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (2, 'same', 'schema-new'), (3, 'schema-other', 'schema-created')", $source_no_shadow ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema no-shadow REPLACE SELECT source', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( sprintf( 'REPLACE INTO `%s` SELECT `pk`, `slug`, `value` FROM `%s`', $target_no_shadow, $source_no_shadow ) ) + ); + $no_shadow_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema no-shadow columnless REPLACE SELECT', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE "__wp_pg_replace_select_', $no_shadow_sql ); + $this->assertStringContainsString( 'FROM "' . $schema_name . '"."' . $source_no_shadow . '"', $no_shadow_sql ); + $this->assertSame( + array( + array( + 'pk' => '2', + 'slug' => 'same', + 'value' => 'schema-new', + ), + array( + 'pk' => '3', + 'slug' => 'schema-other', + 'value' => 'schema-created', + ), + ), + $get_rows( $pdo, $schema_name, $target_no_shadow ) + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (1, 'same', 'schema-old')", $target_shadow ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema shadow REPLACE SELECT target', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` (`pk`, `slug`, `value`) VALUES (4, 'same', 'schema-new'), (5, 'schema-other', 'schema-created')", $source_shadow ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema shadow REPLACE SELECT source', + $backend_sql + ); + + $pdo->exec( + 'CREATE TABLE public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $source_shadow ) + . ' ( + pk integer NOT NULL, + slug varchar(64) NOT NULL, + value varchar(64) NOT NULL, + PRIMARY KEY (pk), + UNIQUE (slug) + )' + ); + $pdo->exec( + 'INSERT INTO public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $source_shadow ) + . " (pk, slug, value) VALUES (6, 'same', 'public-new'), (7, 'public-other', 'public-created')" + ); + + $this->assertSame( + 3, + $driver->query( sprintf( 'REPLACE INTO `%s` SELECT `pk`, `slug`, `value` FROM `%s`', $target_shadow, $source_shadow ) ) + ); + $shadow_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema shadow columnless REPLACE SELECT', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE "__wp_pg_replace_select_', $shadow_sql ); + $this->assertStringContainsString( 'FROM "' . $schema_name . '"."' . $source_shadow . '"', $shadow_sql ); + $this->assertSame( + array( + array( + 'pk' => '4', + 'slug' => 'same', + 'value' => 'schema-new', + ), + array( + 'pk' => '5', + 'slug' => 'schema-other', + 'value' => 'schema-created', + ), + ), + $get_rows( $pdo, $schema_name, $target_shadow ) + ); + $this->assertSame( + array( + array( + 'pk' => '7', + 'slug' => 'public-other', + 'value' => 'public-created', + ), + array( + 'pk' => '6', + 'slug' => 'same', + 'value' => 'public-new', + ), + ), + $get_rows( $pdo, 'public', $source_shadow ) + ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task858' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task858' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task858' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task858' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task858' ) ); + } + } + + /** + * Tests real PostgreSQL LAST_INSERT_ID(id) upserts use catalogs. + */ + public function test_real_pgsql_last_insert_id_upserts_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL LAST_INSERT_ID upsert metadata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task849' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task849' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $explicit_table = 'task849_' . $suffix . '_lid_explicit'; + $select_table = 'task849_' . $suffix . '_lid_select'; + $source_target = 'task849_' . $suffix . '_lid_source_target'; + $source_table = 'task849_' . $suffix . '_lid_source'; + $backend_sql = array(); + $get_row = static function ( PDO $pdo, string $table_name ): array { + $stmt = $pdo->query( + 'SELECT id, slug, hits, updated_at FROM public.' + . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ) + . ' ORDER BY slug' + ); + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + }; + + try { + foreach ( array( $explicit_table, $select_table, $source_target ) as $table_name ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `hits` int NOT NULL DEFAULT 0, + `updated_at` varchar(32) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE LAST_INSERT_ID upsert table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`id`, `slug`, `hits`, `updated_at`) VALUES (7, 'same', 1, '0000-00-00 00:00:00')", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed LAST_INSERT_ID upsert table', + $backend_sql + ); + } + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `seq` int NOT NULL, + `slug` varchar(64) NOT NULL, + `hits` int NOT NULL, + `updated_at` varchar(32) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE LAST_INSERT_ID source table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`seq`, `slug`, `hits`, `updated_at`) VALUES (1, 'same', 2, '2001-01-01 00:00:00')", + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed LAST_INSERT_ID source table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`slug`, `hits`, `updated_at`) VALUES ('same', 2, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), `hits` = `hits` + VALUES(`hits`), `updated_at` = VALUES(`updated_at`)", + $explicit_table + ) + ) + ); + $this->assertSame( 7, $driver->get_insert_id() ); + $explicit_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit-column LAST_INSERT_ID upsert', + $backend_sql + ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE', $explicit_sql ); + $this->assertStringContainsString( '"id" = "' . $explicit_table . '"."id"', $explicit_sql ); + $this->assertStringContainsString( '"hits" = "' . $explicit_table . '"."hits" + excluded."hits"', $explicit_sql ); + $this->assertStringContainsString( '"updated_at" = excluded."updated_at"', $explicit_sql ); + + $explicit_readback = $driver->query( + sprintf( + "SELECT LAST_INSERT_ID() AS last_id, ROW_COUNT() AS row_count_value, `hits`, `updated_at` + FROM `%s` + WHERE `slug` = 'same'", + $explicit_table + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'read explicit-column LAST_INSERT_ID upsert row', + $backend_sql + ); + $this->assertCount( 1, $explicit_readback ); + $this->assertSame( '7', (string) $this->get_row_value( $explicit_readback[0], 'last_id' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $explicit_readback[0], 'row_count_value' ) ); + $this->assertSame( '3', (string) $this->get_row_value( $explicit_readback[0], 'hits' ) ); + $this->assertSame( '2001-01-01 00:00:00', $this->get_row_value( $explicit_readback[0], 'updated_at' ) ); + $this->assertSame( + array( + array( + 'id' => '7', + 'slug' => 'same', + 'hits' => '3', + 'updated_at' => '2001-01-01 00:00:00', + ), + ), + $get_row( $pdo, $explicit_table ) + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` + SELECT NULL, 'same', 2, '2001-01-01 00:00:00' FROM DUAL + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), `hits` = `hits` + VALUES(`hits`), `updated_at` = VALUES(`updated_at`)", + $select_table + ) + ) + ); + $this->assertSame( 7, $driver->get_insert_id() ); + $select_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'columnless SELECT LAST_INSERT_ID upsert', + $backend_sql + ); + $this->assertStringContainsString( 'INSERT INTO "' . $select_table . '" ("id", "slug", "hits", "updated_at") SELECT', $select_sql ); + $this->assertStringContainsString( 'nextval(pg_get_serial_sequence', $select_sql ); + $this->assertStringNotContainsString( 'SELECT NULL, \'same\'', $select_sql ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE', $select_sql ); + $this->assertStringContainsString( '"id" = "' . $select_table . '"."id"', $select_sql ); + $this->assertStringContainsString( '"hits" = "' . $select_table . '"."hits" + excluded."hits"', $select_sql ); + $this->assertStringContainsString( '"updated_at" = excluded."updated_at"', $select_sql ); + + $select_readback = $driver->query( + sprintf( + "SELECT LAST_INSERT_ID() AS last_id, ROW_COUNT() AS row_count_value, `hits`, `updated_at` + FROM `%s` + WHERE `slug` = 'same'", + $select_table + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'read columnless SELECT LAST_INSERT_ID upsert row', + $backend_sql + ); + $this->assertCount( 1, $select_readback ); + $this->assertSame( '7', (string) $this->get_row_value( $select_readback[0], 'last_id' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $select_readback[0], 'row_count_value' ) ); + $this->assertSame( '3', (string) $this->get_row_value( $select_readback[0], 'hits' ) ); + $this->assertSame( '2001-01-01 00:00:00', $this->get_row_value( $select_readback[0], 'updated_at' ) ); + $this->assertSame( + array( + array( + 'id' => '7', + 'slug' => 'same', + 'hits' => '3', + 'updated_at' => '2001-01-01 00:00:00', + ), + ), + $get_row( $pdo, $select_table ) + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + 'INSERT INTO `%s` (`slug`, `hits`, `updated_at`) + SELECT `slug`, `hits`, `updated_at` FROM `%s` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), `hits` = `hits` + VALUES(`hits`), `updated_at` = VALUES(`updated_at`)', + $source_target, + $source_table + ) + ) + ); + $this->assertSame( 7, $driver->get_insert_id() ); + $source_select_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'source-table SELECT LAST_INSERT_ID upsert', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE "__wp_pg_upsert_select_', $source_select_sql ); + $this->assertStringContainsString( 'FROM "' . $source_table . '"', $source_select_sql ); + $this->assertStringContainsString( 'ORDER BY "seq"', $source_select_sql ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE', $source_select_sql ); + $this->assertStringContainsString( '"id" = "' . $source_target . '"."id"', $source_select_sql ); + $this->assertStringContainsString( '"hits" = "' . $source_target . '"."hits" + excluded."hits"', $source_select_sql ); + $this->assertStringContainsString( '"updated_at" = excluded."updated_at"', $source_select_sql ); + + $source_readback = $driver->query( + sprintf( + "SELECT LAST_INSERT_ID() AS last_id, ROW_COUNT() AS row_count_value, `hits`, `updated_at` + FROM `%s` + WHERE `slug` = 'same'", + $source_target + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'read source-table LAST_INSERT_ID upsert row', + $backend_sql + ); + $this->assertCount( 1, $source_readback ); + $this->assertSame( '7', (string) $this->get_row_value( $source_readback[0], 'last_id' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $source_readback[0], 'row_count_value' ) ); + $this->assertSame( '3', (string) $this->get_row_value( $source_readback[0], 'hits' ) ); + $this->assertSame( '2001-01-01 00:00:00', $this->get_row_value( $source_readback[0], 'updated_at' ) ); + $this->assertSame( + array( + array( + 'id' => '7', + 'slug' => 'same', + 'hits' => '3', + 'updated_at' => '2001-01-01 00:00:00', + ), + ), + $get_row( $pdo, $source_target ) + ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task849' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task849' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task849' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task849' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task849' ) ); + } + } + + /** + * Tests real PostgreSQL DML metadata paths use catalogs. + */ + public function test_real_pgsql_dml_metadata_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL DML metadata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task827' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task827' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { + /** + * Captured real PostgreSQL column catalog metadata reads. + * + * @var array[] + */ + private $column_catalog_queries = array(); + + /** + * Captured real PostgreSQL identity catalog metadata reads. + * + * @var array[] + */ + private $identity_catalog_queries = array(); + + /** + * Captured real PostgreSQL unique-index catalog metadata reads. + * + * @var array[] + */ + private $unique_index_catalog_queries = array(); + + /** + * Execute a PostgreSQL query and record DML metadata catalog reads. + * + * @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 information_schema.columns c' ) ) { + $this->column_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + if ( false !== strpos( $sql, 'pg_catalog.pg_get_serial_sequence' ) ) { + $this->identity_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + if ( false !== strpos( $sql, 'pg_catalog.pg_index i' ) && false !== strpos( $sql, 'i.indisunique' ) ) { + $this->unique_index_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get captured real PostgreSQL column catalog metadata reads. + * + * @return array[] Catalog queries. + */ + public function get_column_catalog_queries(): array { + return $this->column_catalog_queries; + } + + /** + * Get captured real PostgreSQL identity catalog metadata reads. + * + * @return array[] Catalog queries. + */ + public function get_identity_catalog_queries(): array { + return $this->identity_catalog_queries; + } + + /** + * Get captured real PostgreSQL unique-index catalog metadata reads. + * + * @return array[] Catalog queries. + */ + public function get_unique_index_catalog_queries(): array { + return $this->unique_index_catalog_queries; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $dml_table = 'task827_' . $suffix . '_dml'; + $source_table = 'task827_' . $suffix . '_source'; + $replace_source = 'task827_' . $suffix . '_replace_source'; + $identity_table = 'task827_' . $suffix . '_identity'; + $schema_name = 'task827_' . $suffix; + $schema_table = 'dml_current_schema'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $schema_table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_table ); + $dml_table_literal = $driver->get_connection()->quote( $dml_table ); + $backend_sql = array(); + $public_table_names = array( $dml_table, $source_table, $replace_source, $identity_table ); + + try { + foreach ( $public_table_names as $table_name ) { + $this->assertStringStartsWith( 'task827_', $table_name ); + } + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL DEFAULT 'seed', + `note` varchar(64) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $dml_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE DML target table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL, + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + `note` varchar(64) NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE columnless INSERT SELECT source table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `slug` varchar(64) NOT NULL, + `value` varchar(64) NOT NULL, + `note` varchar(64) NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $replace_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE REPLACE SELECT source table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $identity_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE identity target table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` VALUES (1, 'alpha', 'one', 'note-a')", $dml_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'columnless INSERT VALUES', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` VALUES (2, 'beta', 'two', 'note-b'), (3, 'delta', 'four', 'note-d')", $source_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'columnless INSERT source rows', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + 'INSERT INTO `%1$s` SELECT `id`, `slug`, `value`, `note` FROM `%2$s` WHERE `id` = 2', + $dml_table, + $source_table + ) + ) + ); + $columnless_insert_select_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'columnless INSERT SELECT', + $backend_sql + ); + $this->assertStringContainsString( + 'INSERT INTO "' . $dml_table . '" ("id", "slug", "value", "note") SELECT', + $columnless_insert_select_sql + ); + $this->assertStringContainsString( 'FROM "' . $source_table . '" WHERE "id" = 2', $columnless_insert_select_sql ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`slug`, `value`, `note`) VALUES ('alpha', 'one-upserted', 'note-up') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `note` = VALUES(`note`)", + $dml_table + ) + ) + ); + $upsert_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'ON DUPLICATE KEY UPDATE catalog unique key', + $backend_sql + ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE', $upsert_sql ); + + $this->assertSame( + 3, + $driver->query( + sprintf( + "REPLACE INTO `%s` (`slug`, `value`, `note`) VALUES + ('beta', 'two-replaced', 'note-r'), + ('gamma', 'three', 'note-g')", + $dml_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'REPLACE VALUES catalog unique key', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` VALUES ('gamma', 'three-replaced', 'note-rs'), ('epsilon', 'five', 'note-e')", $replace_source ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'REPLACE SELECT source rows', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( + sprintf( + 'REPLACE INTO `%1$s` (`slug`, `value`, `note`) SELECT `slug`, `value`, `note` FROM `%2$s`', + $dml_table, + $replace_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'REPLACE SELECT catalog unique key', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`id`, `slug`) VALUES (20, 'manual')", $identity_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit identity INSERT repair', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`id`, `slug`) VALUES (30, 'manual-upsert') + ON DUPLICATE KEY UPDATE `slug` = VALUES(`slug`)", + $identity_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit identity UPSERT repair', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`slug`) VALUES ('generated')", $identity_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'generated identity after repairs', + $backend_sql + ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE DML metadata schema', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `status` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $schema_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema DML table', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` VALUES (1, 'draft'), (2, 'keep')", $schema_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema columnless INSERT', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "UPDATE `%s` SET `status` = 'published' WHERE `id` = 1", $schema_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema UPDATE', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "DELETE FROM `%s` WHERE `status` = 'keep'", $schema_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema DELETE', + $backend_sql + ); + + $this->assertSame( 0, $driver->query( 'USE `' . $database_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE main database after current-schema DML', + $backend_sql + ); + + $dml_rows = $driver->query( + sprintf( + "SELECT `slug`, `value`, `note` FROM `%s` WHERE `slug` IN ('alpha', 'beta', 'gamma', 'epsilon') ORDER BY `slug`", + $dml_table + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'read DML metadata rows', + $backend_sql + ); + $this->assertCount( 4, $dml_rows ); + $this->assertSame( 'one-upserted', $this->get_row_value( $this->find_row_by_value( $dml_rows, 'slug', 'alpha' ), 'value' ) ); + $this->assertSame( 'two-replaced', $this->get_row_value( $this->find_row_by_value( $dml_rows, 'slug', 'beta' ), 'value' ) ); + $this->assertSame( 'three-replaced', $this->get_row_value( $this->find_row_by_value( $dml_rows, 'slug', 'gamma' ), 'value' ) ); + $this->assertSame( 'five', $this->get_row_value( $this->find_row_by_value( $dml_rows, 'slug', 'epsilon' ), 'value' ) ); + + $identity_rows = $driver->query( sprintf( 'SELECT `id`, `slug` FROM `%s` ORDER BY `id`', $identity_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'read identity repair rows', + $backend_sql + ); + $this->assertCount( 3, $identity_rows ); + $this->assertSame( '20', (string) $this->get_row_value( $identity_rows[0], 'id' ) ); + $this->assertSame( '30', (string) $this->get_row_value( $identity_rows[1], 'id' ) ); + $this->assertSame( '31', (string) $this->get_row_value( $identity_rows[2], 'id' ) ); + $this->assertSame( 'generated', $this->get_row_value( $identity_rows[2], 'slug' ) ); + + $schema_rows = $pdo->query( + 'SELECT id, status FROM ' . $schema_sql . '.' . $schema_table_sql . ' ORDER BY id' + )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $schema_rows ); + $this->assertSame( '1', (string) $schema_rows[0]->id ); + $this->assertSame( 'published', $schema_rows[0]->status ); + + $column_catalog_queries = $connection->get_column_catalog_queries(); + $this->assertNotEmpty( $column_catalog_queries ); + $column_catalog_sql = implode( "\n", array_column( $column_catalog_queries, 'sql' ) ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $column_catalog_sql ); + $this->assertStringContainsString( 'c.is_identity', $column_catalog_sql ); + $this->assertStringContainsString( "c.domain_name = '__wp_mysql_datetime' THEN 'datetime'", $column_catalog_sql ); + $this->assertStringContainsString( "c.domain_name = '__wp_mysql_mediumblob' THEN 'mediumblob'", $column_catalog_sql ); + $this->assertStringContainsString( "c.domain_name LIKE '__wp_mysql_varbinary_%' THEN 'varbinary'", $column_catalog_sql ); + $this->assertStringContainsString( "c.data_type = 'numeric' AND c.numeric_precision IS NULL THEN 'numeric'", $column_catalog_sql ); + $this->assertStringContainsString( "'decimal' || CASE", $column_catalog_sql ); + $this->assertStringContainsString( "c.data_type = 'double precision' THEN 'double'", $column_catalog_sql ); + $this->assertStringContainsString( "c.data_type = 'real' THEN 'float'", $column_catalog_sql ); + $this->assertStringContainsString( 'SUBSTRING(c.column_default FROM', $column_catalog_sql ); + $this->assertStringContainsString( "THEN 'CURRENT_TIMESTAMP'", $column_catalog_sql ); + $this->assertStringContainsString( "'CURRENT_TIMESTAMP(' || SUBSTRING(c.column_default FROM", $column_catalog_sql ); + $this->assertStringContainsString( 'pg_catalog.col_description(pc.oid, pa.attnum)', $column_catalog_sql ); + $this->assertStringContainsString( 'convert_from(', $column_catalog_sql ); + $this->assertStringContainsString( 'decode(', $column_catalog_sql ); + $this->assertStringContainsString( '__wp_mysql_column_default:', $column_catalog_sql ); + $this->assertStringContainsString( '__wp_mysql_column_type:', $column_catalog_sql ); + $this->assertStringContainsString( 'DEFAULT_GENERATED', $column_catalog_sql ); + + $identity_catalog_queries = $connection->get_identity_catalog_queries(); + $this->assertNotEmpty( $identity_catalog_queries ); + $identity_catalog_sql = implode( "\n", array_column( $identity_catalog_queries, 'sql' ) ); + $this->assertStringContainsString( 'pg_catalog.pg_get_serial_sequence', $identity_catalog_sql ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $identity_catalog_sql ); + + $unique_index_catalog_queries = $connection->get_unique_index_catalog_queries(); + $this->assertNotEmpty( $unique_index_catalog_queries ); + $unique_index_catalog_sql = implode( "\n", array_column( $unique_index_catalog_queries, 'sql' ) ); + $this->assertStringContainsString( 'pg_catalog.pg_index i', $unique_index_catalog_sql ); + $this->assertStringContainsString( 'i.indisunique', $unique_index_catalog_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_get_indexdef(i.indexrelid', $unique_index_catalog_sql ); + $this->assertStringContainsString( 'COALESCE(column_name, NULLIF(REPLACE(COALESCE(', $unique_index_catalog_sql ); + $this->assertStringContainsString( '"SUB_PART" AS "sub_part"', $unique_index_catalog_sql ); + + foreach ( array_merge( $column_catalog_queries, $identity_catalog_queries, $unique_index_catalog_queries ) as $catalog_query ) { + } + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + $this->assertSame( + 1, + (int) $pdo->query( + "SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = " . $pdo->quote( $dml_table ) + )->fetchColumn() + ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task827' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task827' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task827' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task827' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task827' ) ); + } + } + + /** + * Tests real PostgreSQL complex DML metadata paths use catalogs. + */ + public function test_real_pgsql_complex_dml_metadata_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL complex DML metadata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task831' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task831' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { + /** + * Captured real PostgreSQL catalog column lookups. + * + * @var array[] + */ + private $catalog_column_queries = array(); + + /** + * Execute a PostgreSQL query and record direct column catalog metadata reads. + * + * @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 information_schema.columns c' ) ) { + $this->catalog_column_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get captured real PostgreSQL catalog column lookups. + * + * @return array[] Catalog queries. + */ + public function get_catalog_column_queries(): array { + return $this->catalog_column_queries; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task831_' . $suffix; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $backend_sql = array(); + $get_column = Closure::bind( + function ( string $table_schema, string $table_name, string $column_name ): ?string { + return $this->get_mysql_table_column_name( $table_schema, $table_name, $column_name ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( + 'CREATE TABLE ' . $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( 'lookup_meta' ) . ' ( + "ID" bigint NOT NULL PRIMARY KEY, + "Title" text NOT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE complex DML schema', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE `join_target` ( + `id` int NOT NULL, + `status` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE joined UPDATE target', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE `join_source` ( + `post_id` int NOT NULL, + `meta_key` varchar(40) NOT NULL, + `meta_value` varchar(40) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE joined UPDATE source', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( "INSERT INTO `join_target` VALUES (1, 'draft'), (2, 'draft')" ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed joined UPDATE target', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( + "INSERT INTO `join_source` VALUES + (1, '_status', 'publish'), + (2, '_status', 'private'), + (2, '_other', 'review')" + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed joined UPDATE source', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + "UPDATE `join_target` AS p + JOIN `join_source` AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key = '_status' + AND p.id = 1" + ) + ); + $joined_update_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema joined UPDATE', + $backend_sql + ); + $this->assertStringContainsString( 'UPDATE ' . $schema_sql . '."join_target" AS "p"', $joined_update_sql ); + $this->assertStringContainsString( 'FROM ' . $schema_sql . '."join_source" AS "pm" WHERE (p.id = pm.post_id)', $joined_update_sql ); + $this->assertStringContainsString( 'pm.meta_key = \'_status\'', $joined_update_sql ); + + $this->assertSame( + 1, + $driver->query( + "UPDATE `join_target` AS p + JOIN `join_source` AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key IN ('_status', '_other') + AND p.id = 2 + ORDER BY pm.meta_value ASC + LIMIT 1" + ) + ); + $derived_update_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema derived joined UPDATE', + $backend_sql + ); + $this->assertStringContainsString( 'UPDATE ' . $schema_sql . '."join_target" AS "p"', $derived_update_sql ); + $this->assertStringContainsString( 'FROM ' . $schema_sql . '."join_target" AS "p" JOIN ' . $schema_sql . '."join_source" AS "pm" ON p.id = pm.post_id', $derived_update_sql ); + $this->assertStringContainsString( 'ORDER BY pm.meta_value ASC LIMIT 1', $derived_update_sql ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE `delete_parent` ( + `id` int NOT NULL, + `status` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE multi-target DELETE parent', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE `delete_child` ( + `id` int NOT NULL, + `parent_id` int NOT NULL, + `reason` varchar(20) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE multi-target DELETE child', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( "INSERT INTO `delete_parent` VALUES (10, 'stale'), (11, 'keep')" ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed multi-target DELETE parent', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( "INSERT INTO `delete_child` VALUES (100, 10, 'old'), (101, 11, 'new')" ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed multi-target DELETE child', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( + "DELETE p, c + FROM `delete_parent` AS p + JOIN `delete_child` AS c ON c.parent_id = p.id + WHERE p.status = 'stale'" + ) + ); + $multi_target_delete_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'current-schema multi-target DELETE', + $backend_sql + ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $multi_target_delete_sql ); + $this->assertStringContainsString( 'FROM ' . $schema_sql . '."delete_parent" AS "p" JOIN ' . $schema_sql . '."delete_child" AS "c" ON c.parent_id = p.id', $multi_target_delete_sql ); + $this->assertStringContainsString( 'DELETE FROM ' . $schema_sql . '."delete_parent" AS "p" USING mysql_delete_rows', $multi_target_delete_sql ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE `meta_sample` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `score` int NOT NULL DEFAULT 7, + `title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\', + `body` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE defaults and coercion sample', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( "INSERT INTO `meta_sample` (`body`) VALUES ('Alpha body')" ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert omitted defaults sample', + $backend_sql + ); + + $coercion_rows = $driver->query( "SELECT `id` FROM `meta_sample` WHERE `score` = '7' ORDER BY `id`" ); + $coercion_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'numeric coercion sample', + $backend_sql + ); + $this->assertCount( 1, $coercion_rows ); + $this->assertSame( '1', (string) $this->get_row_value( $coercion_rows[0], 'id' ) ); + $this->assertStringContainsString( + 'SUBSTRING(CAST', + implode( "\n", array_column( $coercion_queries, 'sql' ) ) + ); + + $metadata_rows = $driver->query( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, COLUMN_TYPE, COLLATION_NAME + FROM information_schema.columns + WHERE table_schema = '" . str_replace( "'", "''", $schema_name ) . "' + AND table_name = 'meta_sample' + ORDER BY ORDINAL_POSITION" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema defaults and collation sample', + $backend_sql + ); + $this->assertCount( 4, $metadata_rows ); + $score_column = $this->find_row_by_value( $metadata_rows, 'COLUMN_NAME', 'score' ); + $title_column = $this->find_row_by_value( $metadata_rows, 'COLUMN_NAME', 'title' ); + $body_column = $this->find_row_by_value( $metadata_rows, 'COLUMN_NAME', 'body' ); + $this->assertSame( '7', (string) $this->get_row_value( $score_column, 'COLUMN_DEFAULT' ) ); + $this->assertSame( 'int', $this->get_row_value( $score_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $title_column, 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $body_column, 'COLLATION_NAME' ) ); + + $this->assertSame( 'ID', $get_column( $schema_name, 'lookup_meta', 'id' ) ); + $this->assertSame( 'Title', $get_column( $schema_name, 'lookup_meta', 'title' ) ); + $this->assertSame( 'Title', $get_column( $schema_name, 'lookup_meta', 'Title' ) ); + $this->assertNull( $get_column( $schema_name, 'lookup_meta', 'missing_column' ) ); + + $catalog_queries = $connection->get_catalog_column_queries(); + $this->assertGreaterThanOrEqual( 4, count( $catalog_queries ) ); + foreach ( $catalog_queries as $catalog_query ) { + $sql = (string) ( $catalog_query['sql'] ?? '' ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $sql ); + } + + $join_rows = $pdo->query( + 'SELECT id, status FROM ' . $schema_sql . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( 'join_target' ) + . ' ORDER BY id' + )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 2, $join_rows ); + $this->assertSame( 'publish', $join_rows[0]->status ); + $this->assertSame( 'private', $join_rows[1]->status ); + + $parent_rows = $pdo->query( + 'SELECT id, status FROM ' . $schema_sql . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( 'delete_parent' ) + . ' ORDER BY id' + )->fetchAll( PDO::FETCH_OBJ ); + $child_rows = $pdo->query( + 'SELECT id, parent_id, reason FROM ' . $schema_sql . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( 'delete_child' ) + . ' ORDER BY id' + )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $parent_rows ); + $this->assertCount( 1, $child_rows ); + $this->assertSame( '11', (string) $parent_rows[0]->id ); + $this->assertSame( 'keep', $parent_rows[0]->status ); + $this->assertSame( '101', (string) $child_rows[0]->id ); + $this->assertSame( '11', (string) $child_rows[0]->parent_id ); + + $sample_rows = $pdo->query( + 'SELECT id, score, title, body FROM ' . $schema_sql . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( 'meta_sample' ) + . ' ORDER BY id' + )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $sample_rows ); + $this->assertSame( '1', (string) $sample_rows[0]->id ); + $this->assertSame( '7', (string) $sample_rows[0]->score ); + $this->assertSame( '', $sample_rows[0]->title ); + $this->assertSame( 'Alpha body', $sample_rows[0]->body ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task831' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task831' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task831' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task831' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task831' ) ); + } + } + + /** + * Tests real PostgreSQL CREATE TABLE LIKE/AS/temp paths use catalogs. + */ + public function test_real_pgsql_create_table_like_as_and_temporary_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL CREATE TABLE LIKE/AS catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task867' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task867' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $source_table = 'task867_' . $suffix . '_source'; + $like_table = 'task867_' . $suffix . '_like'; + $ctas_table = 'task867_' . $suffix . '_as'; + $no_as_table = 'task867_' . $suffix . '_no_as'; + $if_exists_table = 'task867_' . $suffix . '_if_exists'; + $if_exists_source = 'task867_' . $suffix . '_if_source'; + $qualified_table = 'task867_' . $suffix . '_qualified'; + $temporary_as_table = 'task867_' . $suffix . '_temp_as'; + $defined_table = 'task867_' . $suffix . '_defined_as'; + $temporary_defined = 'task867_' . $suffix . '_temp_defined_as'; + $temporary_like = 'task867_' . $suffix . '_temp_like'; + $schema_name = 'task867_' . $suffix . '_schema'; + $schema_source = 'schema_source'; + $schema_like = 'schema_like'; + $schema_ctas = 'schema_as'; + $explicit_ctas = 'explicit_ctas'; + $explicit_like = 'explicit_like'; + $explicit_if_exists = 'explicit_if_exists'; + $backend_sql = array(); + $source_literal = $driver->get_connection()->quote( $source_table ); + $like_literal = $driver->get_connection()->quote( $like_table ); + $ctas_literal = $driver->get_connection()->quote( $ctas_table ); + $defined_literal = $driver->get_connection()->quote( $defined_table ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $schema_ctas_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_ctas ); + $explicit_ctas_sql = WP_PostgreSQL_Connection::quote_identifier_value( $explicit_ctas ); + $explicit_like_sql = WP_PostgreSQL_Connection::quote_identifier_value( $explicit_like ); + $explicit_exists_sql = WP_PostgreSQL_Connection::quote_identifier_value( $explicit_if_exists ); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `title` varchar(191) NOT NULL DEFAULT '' COMMENT 'Title note', + `score` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `title_prefix` (`title`(64)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 867 source table'", + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE source table', + $backend_sql + ); + + $this->assertSame( + 3, + $driver->query( + sprintf( + "INSERT INTO `%s` (`slug`, `title`, `score`) VALUES + ('alpha', 'Alpha', 1), + ('beta', 'Beta', 2), + ('gamma', 'Gamma', 3)", + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed source rows', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s` LIKE `%2$s`', + $like_table, + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE LIKE', + $backend_sql + ); + + $like_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $like_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for LIKE copy', + $backend_sql + ); + $this->assertCount( 1, $like_create_rows ); + $like_create_sql = (string) $this->get_row_value( $like_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $like_table . '`', $like_create_sql ); + $this->assertStringContainsString( '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $like_create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $like_create_sql ); + $this->assertStringContainsString( 'KEY `title_prefix` (`title`(64))', $like_create_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 867 source table\'', $like_create_sql ); + $this->assertStringContainsString( 'COMMENT \'Title note\'', $like_create_sql ); + $this->assertStringContainsString( 'COLLATE=utf8mb4_unicode_ci', $like_create_sql ); + + $like_columns = $driver->query( 'SHOW COLUMNS FROM `' . $like_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for LIKE copy', + $backend_sql + ); + $this->assertCount( 4, $like_columns ); + $id_column = $this->find_row_by_value( $like_columns, 'Field', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_column, 'Type' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_column, 'Key' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'Extra' ) ); + $title_column = $this->find_row_by_value( $like_columns, 'Field', 'title' ); + $this->assertSame( 'varchar(191)', $this->get_row_value( $title_column, 'Type' ) ); + $this->assertSame( '', $this->get_row_value( $title_column, 'Default' ) ); + + $like_indexes = $driver->query( 'SHOW INDEX FROM `' . $like_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX for LIKE copy', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $like_indexes, 'Key_name', 'PRIMARY' ) ); + $this->assertNotNull( $this->find_row_by_value( $like_indexes, 'Key_name', 'slug_unique' ) ); + $title_prefix = $this->find_row_by_value( $like_indexes, 'Key_name', 'title_prefix' ); + $this->assertSame( 'title', $this->get_row_value( $title_prefix, 'Column_name' ) ); + $this->assertSame( '64', $this->get_row_value( $title_prefix, 'Sub_part' ) ); + + $table_rows = $driver->query( + "SELECT table_name, engine, table_collation + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ({$source_literal}, {$like_literal}) + ORDER BY table_name" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables for source and LIKE copy', + $backend_sql + ); + $this->assertCount( 2, $table_rows ); + $this->assertSame( $like_table, $this->get_row_value( $this->find_row_by_value( $table_rows, 'TABLE_NAME', $like_table ), 'TABLE_NAME' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $this->find_row_by_value( $table_rows, 'TABLE_NAME', $source_table ), 'ENGINE' ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, column_name, sub_part + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$like_literal} + ORDER BY index_name, seq_in_index" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics for LIKE copy', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'PRIMARY' ) ); + $this->assertNotNull( $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'slug_unique' ) ); + $title_prefix_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'title_prefix' ); + $this->assertSame( 'title', $this->get_row_value( $title_prefix_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '64', $this->get_row_value( $title_prefix_statistic, 'SUB_PART' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s` AS SELECT `id`, `slug`, `score` FROM `%2$s` WHERE `score` >= 2', + $ctas_table, + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE AS SELECT', + $backend_sql + ); + + $ctas_rows = $driver->query( 'SELECT `id`, `slug`, `score` FROM `' . $ctas_table . '` ORDER BY `id`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read CTAS rows', + $backend_sql + ); + $this->assertCount( 2, $ctas_rows ); + $this->assertSame( 'beta', $this->get_row_value( $ctas_rows[0], 'slug' ) ); + $this->assertSame( 'gamma', $this->get_row_value( $ctas_rows[1], 'slug' ) ); + + $ctas_columns = $driver->query( 'SHOW COLUMNS FROM `' . $ctas_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for CTAS table', + $backend_sql + ); + $this->assertCount( 3, $ctas_columns ); + $this->assertNotNull( $this->find_row_by_value( $ctas_columns, 'Field', 'id' ) ); + $this->assertNotNull( $this->find_row_by_value( $ctas_columns, 'Field', 'slug' ) ); + $this->assertNotNull( $this->find_row_by_value( $ctas_columns, 'Field', 'score' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + 'CREATE TEMPORARY TABLE `%1$s` AS SELECT `slug`, `score` FROM `%2$s` WHERE `score` = 2', + $temporary_as_table, + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TEMPORARY TABLE AS SELECT', + $backend_sql + ); + + $temporary_rows = $driver->query( 'SELECT `slug`, `score` FROM `' . $temporary_as_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read temporary CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $temporary_rows ); + $this->assertSame( 'beta', $this->get_row_value( $temporary_rows[0], 'slug' ) ); + $this->assertSame( '2', (string) $this->get_row_value( $temporary_rows[0], 'score' ) ); + + $ctas_catalog_rows = $driver->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$ctas_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables for CTAS table', + $backend_sql + ); + $this->assertCount( 1, $ctas_catalog_rows ); + $this->assertSame( $ctas_table, $this->get_row_value( $ctas_catalog_rows[0], 'TABLE_NAME' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS `%1$s` SELECT `id`, `slug` FROM `%2$s` WHERE `score` = 1', + $no_as_table, + $source_table + ) + ) + ); + $no_as_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE SELECT without AS and with IF NOT EXISTS', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE IF NOT EXISTS ', $no_as_sql ); + $this->assertStringContainsString( '"' . $no_as_table . '" AS SELECT', $no_as_sql ); + $this->assertStringContainsString( 'FROM "' . $source_table . '" WHERE "score" = 1', $no_as_sql ); + $no_as_rows = $driver->query( 'SELECT `id`, `slug` FROM `' . $no_as_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read CREATE TABLE SELECT without AS rows', + $backend_sql + ); + $this->assertCount( 1, $no_as_rows ); + $this->assertSame( 'alpha', $this->get_row_value( $no_as_rows[0], 'slug' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int NOT NULL, + `name` varchar(20) NOT NULL, + KEY `name_key` (`name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $if_exists_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE IF NOT EXISTS preservation target', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`id`, `name`) VALUES (1, 'kept')", $if_exists_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed IF NOT EXISTS preservation target', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` (`changed` int NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $if_exists_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE IF NOT EXISTS source table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( 'INSERT INTO `%s` (`changed`) VALUES (2)', $if_exists_source ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed IF NOT EXISTS source table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS `%1$s` AS SELECT `changed` FROM `%2$s`', + $if_exists_table, + $if_exists_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE IF NOT EXISTS preserves existing table', + $backend_sql + ); + + $if_exists_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $if_exists_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE after IF NOT EXISTS preservation', + $backend_sql + ); + $if_exists_create_sql = (string) $this->get_row_value( $if_exists_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( '`name` varchar(20) NOT NULL', $if_exists_create_sql ); + $this->assertStringContainsString( 'KEY `name_key` (`name`)', $if_exists_create_sql ); + $this->assertStringNotContainsString( '`changed`', $if_exists_create_sql ); + $if_exists_rows = $driver->query( 'SELECT `id`, `name` FROM `' . $if_exists_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read IF NOT EXISTS preserved rows', + $backend_sql + ); + $this->assertCount( 1, $if_exists_rows ); + $this->assertSame( 'kept', $this->get_row_value( $if_exists_rows[0], 'name' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s`.`%2$s` AS SELECT `id`, `slug` FROM `%3$s` WHERE `score` = 2', + $database_name, + $qualified_table, + $source_table + ) + ) + ); + $qualified_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE AS SELECT with main database-qualified target', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE ', $qualified_sql ); + $this->assertStringContainsString( '"' . $qualified_table . '" AS SELECT', $qualified_sql ); + $qualified_rows = $driver->query( 'SELECT `id`, `slug` FROM `' . $qualified_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read main database-qualified CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $qualified_rows ); + $this->assertSame( 'beta', $this->get_row_value( $qualified_rows[0], 'slug' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` int NOT NULL, + `name` varchar(20) NOT NULL COMMENT 'Defined name', + PRIMARY KEY (`id`) + ) COMMENT='Task 867 defined CTAS' + AS SELECT 10 AS id, 'ten' AS name", + $defined_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TABLE AS SELECT with definitions', + $backend_sql + ); + + $defined_rows = $driver->query( 'SELECT `id`, `name` FROM `' . $defined_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read defined CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $defined_rows ); + $this->assertSame( '10', (string) $this->get_row_value( $defined_rows[0], 'id' ) ); + $this->assertSame( 'ten', $this->get_row_value( $defined_rows[0], 'name' ) ); + + $defined_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $defined_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for defined CTAS', + $backend_sql + ); + $defined_create_sql = (string) $this->get_row_value( $defined_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $defined_table . '`', $defined_create_sql ); + $this->assertStringContainsString( '`id` int NOT NULL', $defined_create_sql ); + $this->assertStringContainsString( 'PRIMARY KEY (`id`)', $defined_create_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 867 defined CTAS\'', $defined_create_sql ); + $this->assertStringContainsString( 'COMMENT \'Defined name\'', $defined_create_sql ); + + $defined_catalog_rows = $driver->query( + "SELECT table_name, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$defined_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables for defined CTAS', + $backend_sql + ); + $this->assertCount( 1, $defined_catalog_rows ); + $this->assertSame( 'Task 867 defined CTAS', $this->get_row_value( $defined_catalog_rows[0], 'TABLE_COMMENT' ) ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + "CREATE TEMPORARY TABLE `%s` ( + `id` int NOT NULL, + `name` varchar(20) NOT NULL COMMENT 'Temp defined name', + PRIMARY KEY (`id`) + ) COMMENT='Task 867 temp defined CTAS' + AS SELECT 20 AS id, 'twenty' AS name", + $temporary_defined + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TEMPORARY TABLE AS SELECT with definitions', + $backend_sql + ); + + $temporary_defined_rows = $driver->query( 'SELECT `id`, `name` FROM `' . $temporary_defined . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read temporary defined CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $temporary_defined_rows ); + $this->assertSame( '20', (string) $this->get_row_value( $temporary_defined_rows[0], 'id' ) ); + $this->assertSame( 'twenty', $this->get_row_value( $temporary_defined_rows[0], 'name' ) ); + + $temporary_defined_columns = $driver->query( 'SHOW COLUMNS FROM `' . $temporary_defined . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for temporary defined CTAS', + $backend_sql + ); + $this->assertCount( 2, $temporary_defined_columns ); + $this->assertNotNull( $this->find_row_by_value( $temporary_defined_columns, 'Field', 'id' ) ); + $this->assertNotNull( $this->find_row_by_value( $temporary_defined_columns, 'Field', 'name' ) ); + + $temporary_defined_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $temporary_defined . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for temporary defined CTAS', + $backend_sql + ); + $temporary_defined_create_sql = (string) $this->get_row_value( $temporary_defined_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE `' . $temporary_defined . '`', $temporary_defined_create_sql ); + $this->assertStringContainsString( 'PRIMARY KEY (`id`)', $temporary_defined_create_sql ); + $this->assertStringContainsString( 'COMMENT \'Temp defined name\'', $temporary_defined_create_sql ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TEMPORARY TABLE `%1$s` LIKE `%2$s`', + $temporary_like, + $source_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE TEMPORARY TABLE LIKE', + $backend_sql + ); + + $temporary_like_columns = $driver->query( 'SHOW COLUMNS FROM `' . $temporary_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for temporary LIKE copy', + $backend_sql + ); + $this->assertCount( 4, $temporary_like_columns ); + $this->assertNotNull( $this->find_row_by_value( $temporary_like_columns, 'Field', 'id' ) ); + $this->assertNotNull( $this->find_row_by_value( $temporary_like_columns, 'Field', 'slug' ) ); + + $temporary_like_indexes = $driver->query( 'SHOW INDEX FROM `' . $temporary_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX for temporary LIKE copy', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $temporary_like_indexes, 'Key_name', 'PRIMARY' ) ); + $this->assertNotNull( $this->find_row_by_value( $temporary_like_indexes, 'Key_name', 'slug_unique' ) ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE task867 schema', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` int NOT NULL, + `slug` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`) + ) COMMENT='Task 867 schema source'", + $schema_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema source table', + $backend_sql + ); + + $this->assertSame( + 2, + $driver->query( sprintf( "INSERT INTO `%s` VALUES (1, 'one'), (2, 'two')", $schema_source ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'seed current-schema source table', + $backend_sql + ); + + $this->assertSame( 0, $driver->query( 'USE `' . $database_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE database before explicit non-public CREATE TABLE paths', + $backend_sql + ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%1\$s`.`%2\$s` AS SELECT 7 AS `id`, 'seven' AS `slug`", + $schema_name, + $explicit_ctas + ) + ) + ); + $explicit_ctas_backend_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit non-public CREATE TABLE AS SELECT target', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE ' . $schema_sql . '.' . $explicit_ctas_sql . ' AS SELECT', $explicit_ctas_backend_sql ); + + $explicit_ctas_rows = $driver->query( 'SELECT `id`, `slug` FROM `' . $schema_name . '`.`' . $explicit_ctas . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read explicit non-public CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $explicit_ctas_rows ); + $this->assertSame( '7', (string) $this->get_row_value( $explicit_ctas_rows[0], 'id' ) ); + $this->assertSame( 'seven', $this->get_row_value( $explicit_ctas_rows[0], 'slug' ) ); + + $explicit_ctas_catalog_rows = $driver->query( + 'SELECT table_name + FROM information_schema.tables + WHERE table_schema = ' . $driver->get_connection()->quote( $schema_name ) . ' + AND table_name = ' . $driver->get_connection()->quote( $explicit_ctas ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables for explicit non-public CTAS', + $backend_sql + ); + $this->assertCount( 1, $explicit_ctas_catalog_rows ); + $this->assertSame( $explicit_ctas, $this->get_row_value( $explicit_ctas_catalog_rows[0], 'TABLE_NAME' ) ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s`.`%2$s` LIKE `%1$s`.`%3$s`', + $schema_name, + $explicit_like, + $schema_source + ) + ) + ); + $explicit_like_backend_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit non-public CREATE TABLE LIKE target', + $backend_sql + ); + $this->assertStringContainsString( 'CREATE TABLE ' . $schema_sql . '.' . $explicit_like_sql, $explicit_like_backend_sql ); + + $explicit_like_columns = $driver->query( 'SHOW COLUMNS FROM `' . $schema_name . '`.`' . $explicit_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for explicit non-public LIKE copy', + $backend_sql + ); + $this->assertCount( 2, $explicit_like_columns ); + $this->assertSame( 'int', $this->get_row_value( $this->find_row_by_value( $explicit_like_columns, 'Field', 'id' ), 'Type' ) ); + $this->assertSame( 'varchar(64)', $this->get_row_value( $this->find_row_by_value( $explicit_like_columns, 'Field', 'slug' ), 'Type' ) ); + + $explicit_like_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema_name . '`.`' . $explicit_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for explicit non-public LIKE copy', + $backend_sql + ); + $explicit_like_create_sql = (string) $this->get_row_value( $explicit_like_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $explicit_like . '`', $explicit_like_create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $explicit_like_create_sql ); + + $explicit_like_indexes = $driver->query( 'SHOW INDEX FROM `' . $schema_name . '`.`' . $explicit_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX for explicit non-public LIKE copy', + $backend_sql + ); + $this->assertNotNull( $this->find_row_by_value( $explicit_like_indexes, 'Key_name', 'PRIMARY' ) ); + $this->assertNotNull( $this->find_row_by_value( $explicit_like_indexes, 'Key_name', 'slug_unique' ) ); + + $pdo->exec( + 'CREATE TABLE ' . $schema_sql . '.' . $explicit_exists_sql . ' ( + id integer NOT NULL, + slug text NOT NULL + )' + ); + $pdo->exec( "INSERT INTO {$schema_sql}.{$explicit_exists_sql} (id, slug) VALUES (11, 'kept')" ); + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE IF NOT EXISTS `%1\$s`.`%2\$s` AS SELECT 99 AS `id`, 'changed' AS `slug`", + $schema_name, + $explicit_if_exists + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'explicit non-public CREATE TABLE IF NOT EXISTS target exists', + $backend_sql + ); + $explicit_if_exists_rows = $pdo->query( 'SELECT id, slug FROM ' . $schema_sql . '.' . $explicit_exists_sql )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $explicit_if_exists_rows ); + $this->assertSame( '11', (string) $explicit_if_exists_rows[0]->id ); + $this->assertSame( 'kept', $explicit_if_exists_rows[0]->slug ); + + foreach ( + array( + 'pg_catalog' => 'Unsupported CREATE TABLE statement.', + 'pg_toast' => 'Unsupported CREATE TABLE statement.', + 'information_schema' => 'Unsupported information_schema query.', + ) as $blocked_schema => $expected_message + ) { + try { + $driver->query( 'CREATE TABLE `' . $blocked_schema . '`.`blocked_ctas` AS SELECT 1 AS `id`' ); + $this->fail( 'Expected explicit internal schema CREATE TABLE AS SELECT to fail for ' . $blocked_schema . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $blocked_schema ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $blocked_schema ); + } + } + + foreach ( + array( + 'pg_catalog' => 'Unsupported CREATE TABLE statement.', + 'pg_toast' => 'Unsupported CREATE TABLE statement.', + 'information_schema' => 'Unsupported information_schema query.', + ) as $blocked_schema => $expected_message + ) { + try { + $driver->query( 'CREATE TABLE `' . $blocked_schema . '`.`blocked_like` LIKE `' . $schema_name . '`.`' . $schema_source . '`' ); + $this->fail( 'Expected explicit internal schema CREATE TABLE LIKE to fail for ' . $blocked_schema . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $blocked_schema ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $blocked_schema ); + } + } + + $this->assertSame( 0, $driver->query( 'USE `' . $schema_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE task867 schema after explicit non-public CREATE TABLE paths', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s` LIKE `%2$s`', + $schema_like, + $schema_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema LIKE copy', + $backend_sql + ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%1$s` COMMENT = \'Task 867 schema CTAS\' AS SELECT `id`, `slug` FROM `%2$s` WHERE `id` = 2', + $schema_ctas, + $schema_source + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE current-schema CTAS', + $backend_sql + ); + + $schema_like_columns = $driver->query( 'SHOW COLUMNS FROM `' . $schema_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS for current-schema LIKE copy', + $backend_sql + ); + $this->assertCount( 2, $schema_like_columns ); + $this->assertNotNull( $this->find_row_by_value( $schema_like_columns, 'Field', 'id' ) ); + $this->assertNotNull( $this->find_row_by_value( $schema_like_columns, 'Field', 'slug' ) ); + + $schema_like_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema_like . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for current-schema LIKE copy', + $backend_sql + ); + $schema_like_create_sql = (string) $this->get_row_value( $schema_like_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $schema_like . '`', $schema_like_create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $schema_like_create_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 867 schema source\'', $schema_like_create_sql ); + + $schema_rows = $driver->query( 'SELECT `id`, `slug` FROM `' . $schema_ctas . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'read current-schema CTAS rows', + $backend_sql + ); + $this->assertCount( 1, $schema_rows ); + $this->assertSame( '2', (string) $this->get_row_value( $schema_rows[0], 'id' ) ); + $this->assertSame( 'two', $this->get_row_value( $schema_rows[0], 'slug' ) ); + + $schema_ctas_create_rows = $driver->query( 'SHOW CREATE TABLE `' . $schema_ctas . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE TABLE for current-schema CTAS', + $backend_sql + ); + $schema_ctas_create_sql = (string) $this->get_row_value( $schema_ctas_create_rows[0], 'Create Table' ); + $this->assertStringContainsString( 'CREATE TABLE `' . $schema_ctas . '`', $schema_ctas_create_sql ); + $this->assertStringContainsString( 'COMMENT=\'Task 867 schema CTAS\'', $schema_ctas_create_sql ); + + $schema_table_count = (int) $pdo->query( + 'SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ' . $pdo->quote( $schema_name ) . ' + AND table_name IN (' + . $pdo->quote( $schema_source ) . ', ' + . $pdo->quote( $schema_like ) . ', ' + . $pdo->quote( $schema_ctas ) . ')' + )->fetchColumn(); + $this->assertSame( 3, $schema_table_count ); + + $this->assertSame( 0, $driver->query( 'USE `' . $database_name . '`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE database after current-schema CREATE TABLE paths', + $backend_sql + ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $temporary_as_table ) . ' CASCADE' ); + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $temporary_defined ) . ' CASCADE' ); + $pdo->exec( 'DROP TABLE IF EXISTS ' . WP_PostgreSQL_Connection::quote_identifier_value( $temporary_like ) . ' CASCADE' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task867' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task867' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task867' ) ); + $this->assertSame( array(), $this->get_pgsql_tables_in_schemas_with_prefix( $pdo, 'task867' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task867' ) ); + + $temporary_leftovers = $pdo->prepare( + "SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.oid = pg_catalog.pg_my_temp_schema() + AND c.relname LIKE ? ESCAPE '\\' + ORDER BY c.relname" + ); + $temporary_leftovers->execute( array( 'task867\\_%' ) ); + $this->assertSame( array(), $temporary_leftovers->fetchAll( PDO::FETCH_COLUMN ) ); + } + } + + /** + * Tests real PostgreSQL database/schemata metadata paths use native catalogs. + */ + public function test_real_pgsql_database_and_schemata_metadata_paths_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL database/schemata catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task875' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task875_' . $suffix . '_schema'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $schema_literal = $driver->get_connection()->quote( $schema_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + + $databases = $driver->query( 'SHOW DATABASES' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW DATABASES', + $backend_sql + ); + $database_names = array_map( + function ( $row ): string { + return (string) $this->get_row_value( $row, 'Database' ); + }, + $databases + ); + $this->assertContains( 'information_schema', $database_names ); + $this->assertContains( $database_name, $database_names ); + $this->assertContains( $schema_name, $database_names ); + $this->assertSame( array( 'Database' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $schema_rows = $driver->query( "SHOW DATABASES LIKE 'task875%'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW DATABASES LIKE', + $backend_sql + ); + $this->assertEquals( array( (object) array( 'Database' => $schema_name ) ), $schema_rows ); + + $selected_rows = $driver->query( "SHOW SCHEMAS WHERE `Database` = {$schema_literal} OR `Database` = 'information_schema'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW SCHEMAS WHERE', + $backend_sql + ); + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => $schema_name ), + ), + $selected_rows + ); + $this->assertSame( '2', $driver->query( 'SELECT FOUND_ROWS()' )[0]->{'FOUND_ROWS()'} ); + + $create_rows = $driver->query( 'SHOW CREATE SCHEMA `' . $schema_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE SCHEMA', + $backend_sql + ); + $this->assertCount( 1, $create_rows ); + $this->assertSame( $schema_name, $this->get_row_value( $create_rows[0], 'Database' ) ); + $this->assertSame( + 'CREATE DATABASE `' . $schema_name . '` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + $this->get_row_value( $create_rows[0], 'Create Database' ) + ); + + $create_if_not_exists_rows = $driver->query( 'SHOW CREATE SCHEMA IF NOT EXISTS `' . $schema_name . '`', PDO::FETCH_ASSOC ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE SCHEMA IF NOT EXISTS', + $backend_sql + ); + $this->assertSame( + array( + array( + 'Database' => $schema_name, + 'Create Database' => 'CREATE DATABASE IF NOT EXISTS `' . $schema_name . '` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + ), + ), + $create_if_not_exists_rows + ); + + $this->assertSame( array(), $driver->query( 'SHOW CREATE DATABASE `task875_missing_schema`' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW CREATE DATABASE missing schema', + $backend_sql + ); + + $schemata = $driver->query( + "SELECT schema_name + FROM information_schema.schemata + WHERE schema_name = {$schema_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.schemata', + $backend_sql + ); + $this->assertEquals( array( (object) array( 'SCHEMA_NAME' => $schema_name ) ), $schemata ); + + $star = $driver->query( + "SELECT * + FROM information_schema.schemata + WHERE schema_name = {$schema_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.schemata star', + $backend_sql + ); + $this->assertCount( 1, $star ); + $this->assertSame( 'def', $this->get_row_value( $star[0], 'CATALOG_NAME' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $star[0], 'SCHEMA_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $star[0], 'DEFAULT_CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $star[0], 'DEFAULT_COLLATION_NAME' ) ); + $this->assertNull( $this->get_row_value( $star[0], 'SQL_PATH' ) ); + $this->assertSame( 'NO', $this->get_row_value( $star[0], 'DEFAULT_ENCRYPTION' ) ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $current_database_schemata = $driver->query( + "SELECT schema_name + FROM schemata + WHERE schema_name = {$schema_literal}" + ); + $this->collect_last_postgresql_queries( + $driver, + 'current-database schemata relation', + $backend_sql + ); + $this->assertEquals( array( (object) array( 'SCHEMA_NAME' => $schema_name ) ), $current_database_schemata ); + + $this->assertSame( 0, $driver->query( 'USE `' . $database_name . '`' ) ); + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringContainsString( 'FROM information_schema.schemata s', $all_backend_sql ); + $this->assertStringContainsString( 's.schema_name !~ \'^pg_\'', $all_backend_sql ); + $this->assertStringNotContainsString( 'UNION ALL SELECT', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task875' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task875' ) ); + } + } + + /** + * Tests real PostgreSQL SHOW PLUGINS uses native extension catalogs. + */ + public function test_real_pgsql_show_plugins_uses_postgresql_extension_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL SHOW PLUGINS catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $extension = $pdo->query( + "SELECT + name, + installed_version, + default_version, + comment, + CASE WHEN installed_version IS NULL THEN 'DISABLED' ELSE 'ACTIVE' END AS status, + CASE WHEN installed_version IS NULL THEN 'OFF' ELSE 'ON' END AS load_option + FROM pg_catalog.pg_available_extensions + WHERE name ~ '^[a-z][a-z0-9_]{2,}$' + ORDER BY installed_version IS NULL, name + LIMIT 1" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertIsArray( $extension ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $plugin_name = (string) $extension['name']; + $plugin_status = (string) $extension['status']; + $plugin_version = (string) ( $extension['installed_version'] ?? $extension['default_version'] ?? '' ); + $plugin_type_version = (string) ( $extension['default_version'] ?? '' ); + $plugin_description = (string) ( $extension['comment'] ?? '' ); + $plugin_load_option = (string) $extension['load_option']; + $plugin_name_literal = $driver->get_connection()->quote( $plugin_name ); + $plugin_status_literal = $driver->get_connection()->quote( $plugin_status ); + $plugin_like_literal = $driver->get_connection()->quote( substr( $plugin_name, 0, min( 4, strlen( $plugin_name ) ) ) . '%' ); + $backend_sql = array(); + + $rows = $driver->query( 'SHOW PLUGINS' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PLUGINS', + $backend_sql + ); + $this->assertGreaterThan( 0, count( $rows ) ); + $this->assertSame( array( 'Name', 'Status', 'Type', 'Library', 'License' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $plugin_row = $this->find_row_by_value( $rows, 'Name', $plugin_name ); + $this->assertSame( $plugin_status, $this->get_row_value( $plugin_row, 'Status' ) ); + $this->assertSame( 'EXTENSION', $this->get_row_value( $plugin_row, 'Type' ) ); + $this->assertNull( $this->get_row_value( $plugin_row, 'Library' ) ); + $this->assertSame( '', $this->get_row_value( $plugin_row, 'License' ) ); + + $filtered_rows = $driver->query( "SHOW PLUGINS WHERE Type = 'EXTENSION' AND Status = {$plugin_status_literal}" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PLUGINS WHERE Type and Status', + $backend_sql + ); + $this->find_row_by_value( $filtered_rows, 'Name', $plugin_name ); + $this->assertSame( (string) count( $filtered_rows ), $driver->query( 'SELECT FOUND_ROWS()' )[0]->{'FOUND_ROWS()'} ); + + $like_rows = $driver->query( "SHOW PLUGINS LIKE {$plugin_like_literal}" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PLUGINS LIKE', + $backend_sql + ); + $this->find_row_by_value( $like_rows, 'Name', $plugin_name ); + + $assoc_rows = $driver->query( "SHOW PLUGINS WHERE BINARY Name = {$plugin_name_literal}", PDO::FETCH_ASSOC ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PLUGINS WHERE BINARY Name', + $backend_sql + ); + $this->assertSame( + array( + array( + 'Name' => $plugin_name, + 'Status' => $plugin_status, + 'Type' => 'EXTENSION', + 'Library' => null, + 'License' => '', + ), + ), + $assoc_rows + ); + + $plugin_rows = $driver->query( + "SELECT PLUGIN_NAME, PLUGIN_VERSION, PLUGIN_STATUS, PLUGIN_TYPE, + PLUGIN_TYPE_VERSION, PLUGIN_LIBRARY, PLUGIN_LIBRARY_VERSION, + PLUGIN_AUTHOR, PLUGIN_DESCRIPTION, PLUGIN_LICENSE, LOAD_OPTION + FROM information_schema.plugins + WHERE PLUGIN_NAME = {$plugin_name_literal}" + ); + $plugin_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.plugins real PostgreSQL', + $backend_sql + ); + $this->assertCount( 1, $plugin_rows ); + $this->assertSame( + array( + 'PLUGIN_NAME', + 'PLUGIN_VERSION', + 'PLUGIN_STATUS', + 'PLUGIN_TYPE', + 'PLUGIN_TYPE_VERSION', + 'PLUGIN_LIBRARY', + 'PLUGIN_LIBRARY_VERSION', + 'PLUGIN_AUTHOR', + 'PLUGIN_DESCRIPTION', + 'PLUGIN_LICENSE', + 'LOAD_OPTION', + ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $plugin_direct_row = $plugin_rows[0]; + $this->assertSame( $plugin_name, $this->get_row_value( $plugin_direct_row, 'PLUGIN_NAME' ) ); + $this->assertSame( $plugin_version, $this->get_row_value( $plugin_direct_row, 'PLUGIN_VERSION' ) ); + $this->assertSame( $plugin_status, $this->get_row_value( $plugin_direct_row, 'PLUGIN_STATUS' ) ); + $this->assertSame( 'EXTENSION', $this->get_row_value( $plugin_direct_row, 'PLUGIN_TYPE' ) ); + $this->assertSame( $plugin_type_version, $this->get_row_value( $plugin_direct_row, 'PLUGIN_TYPE_VERSION' ) ); + $this->assertNull( $this->get_row_value( $plugin_direct_row, 'PLUGIN_LIBRARY' ) ); + $this->assertNull( $this->get_row_value( $plugin_direct_row, 'PLUGIN_LIBRARY_VERSION' ) ); + $this->assertSame( '', $this->get_row_value( $plugin_direct_row, 'PLUGIN_AUTHOR' ) ); + $this->assertSame( $plugin_description, $this->get_row_value( $plugin_direct_row, 'PLUGIN_DESCRIPTION' ) ); + $this->assertSame( '', $this->get_row_value( $plugin_direct_row, 'PLUGIN_LICENSE' ) ); + $this->assertSame( $plugin_load_option, $this->get_row_value( $plugin_direct_row, 'LOAD_OPTION' ) ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_available_extensions ae', $plugin_sql ); + $this->assertStringContainsString( 'ae.name AS "PLUGIN_NAME"', $plugin_sql ); + $this->assertStringContainsString( 'COALESCE(ae.installed_version, ae.default_version, \'\') AS "PLUGIN_VERSION"', $plugin_sql ); + $this->assertStringContainsString( 'CASE WHEN ae.installed_version IS NULL THEN \'DISABLED\' ELSE \'ACTIVE\' END AS "PLUGIN_STATUS"', $plugin_sql ); + $this->assertStringContainsString( 'CASE WHEN ae.installed_version IS NULL THEN \'OFF\' ELSE \'ON\' END AS "LOAD_OPTION"', $plugin_sql ); + $this->assertStringContainsString( 'NULL AS "PLUGIN_LIBRARY"', $plugin_sql ); + $this->assertStringContainsString( 'NULL AS "PLUGIN_LIBRARY_VERSION"', $plugin_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $plugin_sql ); + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_available_extensions ae', $all_backend_sql ); + $this->assertStringContainsString( 'p."PLUGIN_NAME" AS "Name"', $all_backend_sql ); + $this->assertStringContainsString( 'ORDER BY p."PLUGIN_NAME"', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests real PostgreSQL direct information_schema tablespace relations use native catalogs. + */ + public function test_real_pgsql_direct_information_schema_tablespace_relations_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema tablespace test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $tablespace = $pdo->query( + "SELECT + CAST(oid AS text) AS oid, + spcname, + NULLIF(pg_catalog.pg_tablespace_location(oid), '') AS location, + COALESCE(pg_catalog.obj_description(oid, 'pg_tablespace'), '') AS comment + FROM pg_catalog.pg_tablespace + ORDER BY spcname + LIMIT 1" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertIsArray( $tablespace ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $tablespace_name = (string) $tablespace['spcname']; + $tablespace_literal = $driver->get_connection()->quote( $tablespace_name ); + $backend_sql = array(); + + $file_rows = $driver->query( + "SELECT FILE_ID, FILE_NAME, FILE_TYPE, TABLESPACE_NAME, ENGINE, STATUS + FROM information_schema.files + WHERE TABLESPACE_NAME = {$tablespace_literal}" + ); + $file_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.files real PostgreSQL', + $backend_sql + ); + $this->assertCount( 1, $file_rows ); + $this->assertSame( + array( 'FILE_ID', 'FILE_NAME', 'FILE_TYPE', 'TABLESPACE_NAME', 'ENGINE', 'STATUS' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $file_row = $file_rows[0]; + $this->assertSame( (string) $tablespace['oid'], $this->get_row_value( $file_row, 'FILE_ID' ) ); + $this->assertSame( $tablespace['location'], $this->get_row_value( $file_row, 'FILE_NAME' ) ); + $this->assertSame( 'TABLESPACE', $this->get_row_value( $file_row, 'FILE_TYPE' ) ); + $this->assertSame( $tablespace_name, $this->get_row_value( $file_row, 'TABLESPACE_NAME' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $file_row, 'ENGINE' ) ); + $this->assertSame( 'NORMAL', $this->get_row_value( $file_row, 'STATUS' ) ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_tablespace ts', $file_sql ); + $this->assertStringContainsString( 'CAST(ts.oid AS bigint) AS "FILE_ID"', $file_sql ); + $this->assertStringContainsString( 'NULLIF(pg_catalog.pg_tablespace_location(ts.oid), \'\') AS "FILE_NAME"', $file_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $file_sql ); + + $tablespace_rows = $driver->query( + "SELECT TABLESPACE_NAME, ENGINE, TABLESPACE_TYPE, TABLESPACE_COMMENT + FROM information_schema.tablespaces + WHERE TABLESPACE_NAME = {$tablespace_literal}" + ); + $tablespace_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tablespaces real PostgreSQL', + $backend_sql + ); + $this->assertCount( 1, $tablespace_rows ); + $this->assertSame( + array( 'TABLESPACE_NAME', 'ENGINE', 'TABLESPACE_TYPE', 'TABLESPACE_COMMENT' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $tablespace_row = $tablespace_rows[0]; + $this->assertSame( $tablespace_name, $this->get_row_value( $tablespace_row, 'TABLESPACE_NAME' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $tablespace_row, 'ENGINE' ) ); + $this->assertSame( 'General', $this->get_row_value( $tablespace_row, 'TABLESPACE_TYPE' ) ); + $this->assertSame( (string) $tablespace['comment'], $this->get_row_value( $tablespace_row, 'TABLESPACE_COMMENT' ) ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_tablespace ts', $tablespace_sql ); + $this->assertStringContainsString( 'ts.spcname AS "TABLESPACE_NAME"', $tablespace_sql ); + $this->assertStringContainsString( 'pg_catalog.obj_description(ts.oid, \'pg_tablespace\')', $tablespace_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $tablespace_sql ); + + $extension_rows = $driver->query( + "SELECT TABLESPACE_NAME, ENGINE_ATTRIBUTE + FROM information_schema.tablespaces_extensions + WHERE TABLESPACE_NAME = {$tablespace_literal}" + ); + $extension_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tablespaces_extensions real PostgreSQL', + $backend_sql + ); + $this->assertCount( 1, $extension_rows ); + $this->assertSame( + array( 'TABLESPACE_NAME', 'ENGINE_ATTRIBUTE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( $tablespace_name, $this->get_row_value( $extension_rows[0], 'TABLESPACE_NAME' ) ); + $this->assertNull( $this->get_row_value( $extension_rows[0], 'ENGINE_ATTRIBUTE' ) ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_tablespace ts', $extension_sql ); + $this->assertStringContainsString( 'NULL AS "ENGINE_ATTRIBUTE"', $extension_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $extension_sql ); + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests real PostgreSQL text quote paths use native PostgreSQL. + */ + public function test_real_pgsql_text_quote_upsert_and_temporal_paths_use_postgresql(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL text quote catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task881' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $text_table = 'task881_' . $suffix . '_text'; + $options_table = 'task881_' . $suffix . '_options'; + $temporal_table = 'task881_' . $suffix . '_temporal'; + $text_table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $text_table ); + $options_table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $options_table ); + $text_with_nul = "protected\0property"; + $length_value = "a\0\xC3\xA9"; + $external_value = 'pre' . "\xEE\x80\x80" . '0post'; + $serialized_payload = serialize( + array( + "\0*\0data" => "single ' double \" backslash \\ marker E'\nnext line", + ) + ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `value` longtext NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $text_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE text quote table', + $backend_sql + ); + + $connection->query( + 'INSERT INTO ' . $text_table_sql . ' (value) VALUES (' . $connection->quote( $text_with_nul ) . ')' + ); + $connection->query( + 'INSERT INTO ' . $text_table_sql . ' (value) VALUES (?)', + array( $external_value ) + ); + $connection->query( + 'INSERT INTO ' . $text_table_sql . ' (value) VALUES (' . $connection->quote( $length_value ) . ')' + ); + + $stored_rows = $connection->query( 'SELECT value FROM ' . $text_table_sql . ' ORDER BY id' )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 3, $stored_rows ); + $this->assertStringNotContainsString( "\0", $stored_rows[0]->value ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_rows[0]->value ); + $this->assertSame( $external_value, $stored_rows[1]->value ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_rows[2]->value ); + + $decoded_rows = $driver->query( 'SELECT value FROM `' . $text_table . '` ORDER BY id' ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT decoded text rows', + $backend_sql + ); + $this->assertCount( 3, $decoded_rows ); + $this->assertSame( $text_with_nul, $decoded_rows[0]->value ); + $this->assertSame( $external_value, $decoded_rows[1]->value ); + $this->assertSame( $length_value, $decoded_rows[2]->value ); + + $length_rows = $driver->query( 'SELECT LENGTH(value) AS byte_length FROM `' . $text_table . '` WHERE `id` = 3' ); + $this->collect_last_postgresql_queries( + $driver, + 'LENGTH decoded PostgreSQL text envelope', + $backend_sql + ); + $this->assertCount( 1, $length_rows ); + $this->assertSame( '4', (string) $length_rows[0]->byte_length ); + $length_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'STRPOS(SUBSTR(CAST(value AS text),', $length_sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(value AS text), 'UTF8')) END AS byte_length", $length_sql ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `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`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $options_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE options quote table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`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`)", + $options_table, + $this->quote_mysql_string_literal_for_test( $serialized_payload ) + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'serialized payload upsert', + $backend_sql + ); + $stored_option = (string) $connection->query( 'SELECT option_value FROM ' . $options_table_sql )->fetchColumn(); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_option ); + + $option_rows = $driver->query( 'SELECT option_value, autoload FROM `' . $options_table . "` WHERE `option_name` = '_transient_feed_quote_test'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT decoded serialized payload', + $backend_sql + ); + $this->assertCount( 1, $option_rows ); + $this->assertSame( $serialized_payload, $option_rows[0]->option_value ); + $this->assertSame( 'off', $option_rows[0]->autoload ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `date_value` date DEFAULT NULL, + `datetime_value` datetime DEFAULT NULL, + `timestamp_value` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $temporal_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE temporal quote table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`date_value`, `datetime_value`, `timestamp_value`) + VALUES ('2024-01-02', '2024-01-03 04:05:06', '2024-01-04 05:06:07')", + $temporal_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT temporal quote row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + 'UPDATE `%s` + SET `date_value` = `datetime_value`, + `datetime_value` = `timestamp_value` + WHERE `id` = 1', + $temporal_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'strict temporal update', + $backend_sql + ); + $temporal_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '(SELECT CASE WHEN "__wp_pg_mysql_temporal_value"."value" IS NULL THEN NULL', $temporal_sql ); + $this->assertStringContainsString( 'FROM (SELECT CAST("datetime_value" AS text) AS "value") AS "__wp_pg_mysql_temporal_value"', $temporal_sql ); + $this->assertStringContainsString( 'FROM (SELECT CAST("timestamp_value" AS text) AS "value") AS "__wp_pg_mysql_temporal_value"', $temporal_sql ); + $this->assertStringContainsString( "'__wp_pg_invalid_temporal__' || COALESCE", $temporal_sql ); + $this->assertStringContainsString( 'BETWEEN 1 AND 9999', $temporal_sql ); + $this->assertStringNotContainsString( '__wp_pg_mysql_validate_temporal', $temporal_sql ); + + $this->assertNotEmpty( $backend_sql ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task881' ); + } + } + + /** + * Tests unsupported SHOW TABLE STATUS WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_table_status_where_clause_does_not_reach_backend(): void { + $unsupported_queries = array( + 'SHOW TABLE STATUS WHERE Name LIKE wptests_%', + 'SHOW TABLE STATUS WHERE Name = wptests_options', + ); + + 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 real PostgreSQL SHOW TABLE STATUS WHERE Auto_increment filters match SQLite parity. + */ + public function test_real_pgsql_show_table_status_where_auto_increment_filters_match_sqlite_parity(): void { + $driver = $this->create_driver(); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $low = 'show_where_low_' . $suffix; + $high = 'show_where_high_' . $suffix; + $plain = 'show_where_plain_' . $suffix; + + foreach ( array( $low, $high ) as $table_name ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(20) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + } + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int DEFAULT NULL, + `name` varchar(20) DEFAULT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $plain + ) + ) + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `{$low}` (`name`) VALUES ('a')" ) ); + $this->assertSame( 5, $driver->query( "INSERT INTO `{$high}` (`name`) VALUES ('a'), ('b'), ('c'), ('d'), ('e')" ) ); + + $high_rows = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment > 3' ); + $this->assertSame( array( $high ), array_map( array( $this, 'get_show_table_status_row_name' ), $high_rows ) ); + + $plain_rows = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment IS NULL' ); + $this->assertSame( array( $plain ), array_map( array( $this, 'get_show_table_status_row_name' ), $plain_rows ) ); + + $high_literal = $driver->get_connection()->quote( $high ); + $and_rows = $driver->query( 'SHOW TABLE STATUS WHERE Name = ' . $high_literal . ' AND Auto_increment > 3' ); + $this->assertSame( array( $high ), array_map( array( $this, 'get_show_table_status_row_name' ), $and_rows ) ); + } + + /** + * Tests real PostgreSQL SHOW INDEX WHERE pushdown filters match SQLite parity. + */ + public function test_real_pgsql_show_index_where_pushdown_filters_match_sqlite_parity(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'show_where_index_' . $suffix; + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) NOT NULL, + `title` varchar(191) NOT NULL, + `body` text, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `title_prefix` (`title`(32)), + FULLTEXT KEY `body_fulltext` (`body`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + + $primary_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Key_name = 'PRIMARY'" ); + $primary_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $primary_rows ); + $this->assertSame( 'PRIMARY', $this->get_row_value( $primary_rows[0], 'Key_name' ) ); + $this->assertStringContainsString( 'CASE WHEN CAST("Key_name" AS text) ~ ', $primary_queries[0]['sql'] ); + $this->assertStringContainsString( 'translate(CAST("Key_name" AS text), ', $primary_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, 'PRIMARY', 'PRIMARY', 'PRIMARY' ), $primary_queries[0]['params'] ); + + $lower_primary_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Key_name = 'primary'" ); + $lower_primary_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $lower_primary_rows ); + $this->assertSame( 'PRIMARY', $this->get_row_value( $lower_primary_rows[0], 'Key_name' ) ); + $this->assertStringContainsString( 'translate(CAST("Key_name" AS text), ', $lower_primary_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, 'primary', 'primary', 'primary' ), $lower_primary_queries[0]['params'] ); + + $unique_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Non_unique = 0" ); + $unique_queries = $driver->get_last_postgresql_queries(); + $unique_names = array_map( + static function ( $row ): string { + return $row->Key_name; + }, + $unique_rows + ); + $this->assertCount( 2, $unique_rows ); + $this->assertContains( 'PRIMARY', $unique_names ); + $this->assertContains( 'slug_unique', $unique_names ); + $this->assertStringContainsString( 'CASE WHEN CAST("Non_unique" AS text) ~ ', $unique_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, '0', '0', '0' ), $unique_queries[0]['params'] ); + + $numeric_string_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Non_unique = '00'" ); + $numeric_string_queries = $driver->get_last_postgresql_queries(); + $numeric_string_names = array_map( + static function ( $row ): string { + return $row->Key_name; + }, + $numeric_string_rows + ); + $this->assertCount( 2, $numeric_string_rows ); + $this->assertContains( 'PRIMARY', $numeric_string_names ); + $this->assertContains( 'slug_unique', $numeric_string_names ); + $this->assertStringContainsString( 'CASE WHEN CAST("Non_unique" AS text) ~ ', $numeric_string_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, '00', '00', '00' ), $numeric_string_queries[0]['params'] ); + + $fulltext_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Index_type = 'FULLTEXT'" ); + $fulltext_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $fulltext_rows ); + $this->assertSame( 'body_fulltext', $this->get_row_value( $fulltext_rows[0], 'Key_name' ) ); + $this->assertStringContainsString( 'translate(CAST("Index_type" AS text), ', $fulltext_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, 'FULLTEXT', 'FULLTEXT', 'FULLTEXT' ), $fulltext_queries[0]['params'] ); + + $prefix_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Sub_part = 32" ); + $prefix_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $prefix_rows ); + $this->assertSame( 'title_prefix', $this->get_row_value( $prefix_rows[0], 'Key_name' ) ); + $this->assertSame( '32', $this->get_row_value( $prefix_rows[0], 'Sub_part' ) ); + $this->assertStringContainsString( 'CASE WHEN CAST("Sub_part" AS text) ~ ', $prefix_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, '32', '32', '32' ), $prefix_queries[0]['params'] ); + + $like_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Key_name LIKE 'title%' AND Non_unique = 1" ); + $like_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $like_rows ); + $this->assertSame( 'title_prefix', $this->get_row_value( $like_rows[0], 'Key_name' ) ); + $this->assertStringNotContainsString( ' LIKE translate(CAST(? AS text), ', $like_queries[0]['sql'] ); + $this->assertStringNotContainsString( 'CASE WHEN CAST("Non_unique" AS text) ~ ', $like_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name ), $like_queries[0]['params'] ); + } + + /** + * Tests real PostgreSQL SHOW COLUMNS WHERE pushdown filters match SQLite parity. + */ + public function test_real_pgsql_show_columns_where_pushdown_filters_match_sqlite_parity(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'show_where_columns_' . $suffix; + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `val1` int NOT NULL, + `val2` int NOT NULL, + `name` varchar(64) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + + $like_columns = $driver->query( "SHOW COLUMNS FROM `{$table_name}` LIKE 'val%'" ); + $this->assertSame( + array( 'val1', 'val2' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $like_columns + ) + ); + + $field_rows = $driver->query( "SHOW COLUMNS FROM `{$table_name}` WHERE Field = 'val1'" ); + $field_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $field_rows ); + $this->assertSame( 'val1', $this->get_row_value( $field_rows[0], 'Field' ) ); + $this->assertStringContainsString( 'CASE WHEN CAST("COLUMN_NAME" AS text) ~ ', $field_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name, 'val1', 'val1', 'val1' ), $field_queries[0]['params'] ); + + $upper_like_rows = $driver->query( "SHOW COLUMNS FROM `{$table_name}` WHERE Field LIKE 'VAL%'" ); + $upper_like_queries = $driver->get_last_postgresql_queries(); + $this->assertSame( + array( 'val1', 'val2' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $upper_like_rows + ) + ); + $this->assertStringNotContainsString( ' LIKE translate(CAST(? AS text), ', $upper_like_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name ), $upper_like_queries[0]['params'] ); + + $underscore_like_rows = $driver->query( "SHOW COLUMNS FROM `{$table_name}` WHERE Field LIKE 'val_'" ); + $underscore_like_queries = $driver->get_last_postgresql_queries(); + $this->assertSame( + array( 'val1', 'val2' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $underscore_like_rows + ) + ); + $this->assertStringNotContainsString( ' LIKE translate(CAST(? AS text), ', $underscore_like_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name ), $underscore_like_queries[0]['params'] ); + + $and_rows = $driver->query( "SHOW COLUMNS FROM `{$table_name}` WHERE Field LIKE 'val%' AND Type = 'int'" ); + $and_queries = $driver->get_last_postgresql_queries(); + $this->assertSame( + array( 'val1', 'val2' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $and_rows + ) + ); + $this->assertStringNotContainsString( ' LIKE translate(CAST(? AS text), ', $and_queries[0]['sql'] ); + $this->assertStringNotContainsString( 'CASE WHEN CAST("COLUMN_TYPE" AS text) ~ ', $and_queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name ), $and_queries[0]['params'] ); + } + + /** + * Tests missing current-prefix install metadata probes return empty result sets. + */ + public function test_real_pgsql_missing_install_state_metadata_probes_return_empty_results(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $existing_table = 'taskbz_' . $suffix . '_wp_options'; + $missing_table = 'taskbz_' . $suffix . '_wp_e2e_options'; + $existing_like = "'taskbz\\\\_{$suffix}\\\\_wp\\\\_options'"; + $missing_like = "'taskbz\\\\_{$suffix}\\\\_wp\\\\_e2e\\\\_options'"; + $tables_column = 'Tables_in_' . $schema_name; + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `option_name` varchar(191) NOT NULL DEFAULT '', + `option_value` longtext NOT NULL, + PRIMARY KEY (`option_id`), + UNIQUE KEY `option_name` (`option_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $existing_table + ) + ) + ); + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`option_name`, `option_value`) VALUES ('siteurl', 'http://old-prefix.example')", + $existing_table + ) + ) + ); + + $this->assertSame( array(), $driver->query( 'DESCRIBE `' . $missing_table . '`' ) ); + $this->assertSame( array(), $driver->query( 'SHOW COLUMNS FROM `' . $missing_table . '`' ) ); + $this->assertSame( array(), $driver->query( 'SHOW TABLES LIKE ' . $missing_like ) ); + + $existing_tables = $driver->query( 'SHOW TABLES LIKE ' . $existing_like ); + $this->assertCount( 1, $existing_tables ); + $this->assertSame( $tables_column, $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( $existing_table, $existing_tables[0]->{$tables_column} ); + } + + /** + * Tests SHOW FULL COLUMNS WHERE LIKE does not push down newline-overmatching patterns. + */ + public function test_real_pgsql_show_columns_full_where_like_percent_uses_php_newline_semantics(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'show_where_newline_' . $suffix; + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL COMMENT 'line1\\nline2', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $table_name + ) + ) + ); + + $this->clear_driver_mysql_metadata_caches( $driver ); + + $rows = $driver->query( "SHOW FULL COLUMNS FROM `{$table_name}` WHERE Comment LIKE '%'" ); + $queries = $driver->get_last_postgresql_queries(); + + $this->assertSame( + array( 'id' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $rows + ) + ); + $this->assertStringNotContainsString( ' LIKE translate(CAST(? AS text), ', $queries[0]['sql'] ); + $this->assertSame( array( $schema_name, $table_name ), $queries[0]['params'] ); + } + + /** + * Tests real PostgreSQL SHOW DATABASES WHERE filters match SQLite parity. + */ + public function test_real_pgsql_show_databases_where_filters_match_sqlite_parity(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $schema_literal = $driver->get_connection()->quote( $schema_name ); + + $equal_rows = $driver->query( 'SHOW DATABASES WHERE `Database` = ' . $schema_literal ); + $this->assertEquals( array( (object) array( 'Database' => $schema_name ) ), $equal_rows ); + + $and_rows = $driver->query( + 'SHOW SCHEMAS WHERE `Database` LIKE ' . + $driver->get_connection()->quote( 'wp_pg_test_%' ) . + ' AND `Database` = ' . + $schema_literal + ); + $this->assertEquals( array( (object) array( 'Database' => $schema_name ) ), $and_rows ); + + $or_rows = $driver->query( + 'SHOW SCHEMAS WHERE `Database` = ' . + $schema_literal . + " OR `Database` = 'information_schema'" + ); + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => $schema_name ), + ), + $or_rows + ); + } + + /** + * Tests real PostgreSQL SHOW WHERE function predicates stay PHP filtered. + */ + public function test_real_pgsql_show_where_function_predicates_stay_php_filtered(): void { + $driver = $this->create_driver(); + $schema_name = $driver->query( 'SELECT DATABASE()' )[0]->{'DATABASE()'}; + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'show_where_function_' . $suffix; + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ) + ); + + $table_literal = $driver->get_connection()->quote( $table_name ); + $rows = $driver->query( 'SHOW TABLE STATUS WHERE LOWER(Name) = LOWER(' . $table_literal . ')' ); + $queries = $driver->get_last_postgresql_queries(); + + $this->assertSame( array( $table_name ), array_map( array( $this, 'get_show_table_status_row_name' ), $rows ) ); + $this->assertCount( 1, $queries ); + $this->assertSame( array( $schema_name, 'BASE TABLE' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( $table_name, $queries[0]['sql'] ); + } + + /** + * Tests unsupported catalog SHOW WHERE clauses still fail closed. + */ + public function test_unsupported_catalog_show_where_clauses_still_fail_closed(): void { + $driver = $this->create_driver(); + $table_name = 'unsupported_catalog_show_where'; + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE `{$table_name}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(20) DEFAULT NULL, + KEY `name_prefix` (`name`(10)), + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ) + ); + + $unsupported_queries = array( + 'SHOW TABLE STATUS WHERE Name LIKE wptests_%' => 'Unsupported SHOW TABLE STATUS statement.', + 'SHOW TABLE STATUS WHERE Name = wptests_options' => 'Unsupported SHOW TABLE STATUS statement.', + "SHOW INDEX FROM `{$table_name}` WHERE Key_name LIKE 'x%' ESCAPE '!!'" => 'Unsupported SHOW INDEX statement.', + ); + + foreach ( $unsupported_queries as $query => $message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported catalog SHOW WHERE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW GRANTS returns a static MySQL-shaped grants row. + */ + public function test_show_grants_returns_static_mysql_shaped_row(): void { + $driver = $this->create_driver(); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( 'SHOW GRANTS', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 1, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Grants for root@%', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Grants for root@%', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 4096, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + + $assoc = $driver->query( 'SHOW GRANTS', PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'Grants for root@%' => $this->get_show_grants_expected_value(), + ), + ), + $assoc + ); + + $num = $driver->query( 'SHOW GRANTS', PDO::FETCH_NUM ); + $this->assertSame( array( array( $this->get_show_grants_expected_value() ) ), $num ); + } + + /** + * Tests supported SHOW GRANTS FOR/USING forms return the static grants row. + */ + public function test_show_grants_for_and_using_forms_return_static_row(): void { + $queries = array( + 'SHOW GRANTS FOR current_user();', + 'SHOW GRANTS FOR CURRENT_USER', + 'sHoW gRaNtS FoR CuRrEnT_UsEr()', + 'SHOW GRANTS FOR root', + "SHOW GRANTS FOR 'root'@'localhost'", + 'SHOW GRANTS USING role1', + 'SHOW GRANTS FOR CURRENT_USER() USING role1', + 'SHOW GRANTS FOR u@h USING r1,r2', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $grants = $driver->query( $query ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants, $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array( 'Grants for root@%' ), array_column( $driver->get_last_column_meta(), 'name' ), $query ); + } + } + + /** + * Tests SHOW GRANTS updates FOUND_ROWS() accounting. + */ + public function test_show_grants_sets_found_rows_to_one(): void { + $driver = $this->create_driver(); + + $driver->query( 'SHOW GRANTS' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW GRANTS is not table-scoped under USE information_schema. + */ + public function test_show_grants_after_use_information_schema_returns_static_row(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'information_schema', $driver->get_last_column_meta()[0]['mysqli:db'] ); + } + + /** + * Tests malformed SHOW GRANTS syntax fails before backend execution. + */ + public function test_malformed_show_grants_syntax_fails_closed(): void { + $queries = array( + 'SHOW GRANTS FOR', + 'SHOW GRANTS USING', + 'SHOW GRANTS FOR CURRENT_USER(1)', + 'SHOW GRANTS FOR root USING', + "SHOW GRANTS FOR root USING role1 WHERE User = 'root'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW GRANTS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW GRANTS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW STATUS returns bounded MySQL-shaped status rows. + */ + public function test_show_status_returns_bounded_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW STATUS' ); + + $status = array(); + foreach ( $rows as $row ) { + $status[ $row->Variable_name ] = $row->Value; + } + + $this->assertSame( '0', $status['Uptime'] ); + $this->assertSame( '1', $status['Threads_connected'] ); + $this->assertSame( '0', $status['Questions'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'Variable_name', 'Value' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $threads = $driver->query( "SHOW GLOBAL STATUS LIKE 'Threads_%'" ); + $this->assertSame( + array( 'Threads_cached', 'Threads_connected', 'Threads_created', 'Threads_running' ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $bare_threads = $driver->query( "SHOW STATUS LIKE 'Threads_%'" ); + $this->assertSame( + array( 'Threads_cached', 'Threads_connected', 'Threads_created', 'Threads_running' ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $bare_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $uptime = $driver->query( "SHOW SESSION STATUS WHERE Variable_name = 'Uptime'" ); + $this->assertCount( 1, $uptime ); + $this->assertSame( 'Uptime', $uptime[0]->Variable_name ); + $this->assertSame( '0', $uptime[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $handlers = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Handler_read_%'" ); + $this->assertSame( + array( + 'Handler_read_first', + 'Handler_read_key', + 'Handler_read_next', + 'Handler_read_prev', + 'Handler_read_rnd', + 'Handler_read_rnd_next', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $handlers + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $zero_values = $driver->query( "SHOW STATUS WHERE Value = '0'" ); + $zero_names = array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $zero_values + ); + $this->assertContains( 'Uptime', $zero_names ); + $this->assertContains( 'Questions', $zero_names ); + $this->assertNotContains( 'Threads_connected', $zero_names ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $running_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads_%' AND Value = '1'" ); + $this->assertSame( + array( + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $running_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $non_zero_values = $driver->query( "SHOW STATUS WHERE Value <> '0'" ); + $this->assertSame( + array( + 'Connections', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $non_zero_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $thread_or_one_values = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads_%' OR Value = '1'" ); + $this->assertSame( + array( + 'Connections', + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $thread_or_one_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $case_insensitive_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'threads_%'" ); + $this->assertSame( + array( + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $case_insensitive_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $binary_threads = $driver->query( "SHOW STATUS WHERE BINARY Variable_name LIKE 'threads_%'" ); + $this->assertSame( array(), $binary_threads ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $escaped_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads!_%' ESCAPE '!'" ); + $this->assertSame( + array( + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $escaped_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $selected_values = $driver->query( "SHOW STATUS WHERE Variable_name IN ('Uptime', 'Threads_running')" ); + $this->assertSame( + array( + 'Threads_running', + 'Uptime', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $selected_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $unknown_not_in_values = $driver->query( "SHOW STATUS WHERE Value NOT IN ('0', NULL)" ); + $this->assertSame( array(), $unknown_not_in_values ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $truthy_status = $driver->query( 'SHOW STATUS WHERE NOT 0' ); + $this->assertNotCount( 0, $truthy_status ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( (string) count( $truthy_status ), $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests unsupported SHOW STATUS clauses fail before backend execution. + */ + public function test_unsupported_show_status_clauses_fail_closed(): void { + $queries = array( + "SHOW STATUS WHERE Unknown = '0'", + 'SHOW STATUS LIMIT 1', + 'SHOW STATUS LIKE Threads_%', + "SHOW STATUS WHERE Variable_name LIKE 'Threads!!%' ESCAPE '!!'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW STATUS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW STATUS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW WARNINGS/ERRORS expose empty MySQL-shaped diagnostics. + */ + public function test_show_warnings_and_errors_return_empty_mysql_shaped_diagnostics(): void { + $driver = $this->create_driver(); + + $warnings = $driver->query( 'SHOW WARNINGS' ); + $this->assertSame( array(), $warnings ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + + $errors = $driver->query( 'SHOW ERRORS LIMIT 0, 10', PDO::FETCH_ASSOC ); + $this->assertSame( array(), $errors ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $limited_warnings = $driver->query( 'SHOW WARNINGS LIMIT 1 OFFSET 0' ); + $this->assertSame( array(), $limited_warnings ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $warnings_count = $driver->query( 'SHOW COUNT(*) WARNINGS' ); + $this->assertSame( '0', $warnings_count[0]->{'@@session.warning_count'} ); + $this->assertSame( array( '@@session.warning_count' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $errors_count = $driver->query( 'SHOW COUNT(*) ERRORS', PDO::FETCH_NUM ); + $this->assertSame( array( array( '0' ) ), $errors_count ); + $this->assertSame( array( '@@session.error_count' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW WARNINGS/ERRORS clauses fail before backend execution. + */ + public function test_unsupported_show_warnings_and_errors_clauses_fail_closed(): void { + $queries = array( + "SHOW WARNINGS WHERE Level = 'Warning'" => 'Unsupported SHOW WARNINGS statement.', + 'SHOW WARNINGS LIMIT bad' => 'Unsupported SHOW WARNINGS statement.', + 'SHOW WARNINGS LIMIT 1, bad' => 'Unsupported SHOW WARNINGS statement.', + 'SHOW COUNT(*) WARNINGS LIMIT 1' => 'Unsupported SHOW WARNINGS statement.', + "SHOW ERRORS LIKE 'error%'" => 'Unsupported SHOW ERRORS statement.', + 'SHOW ERRORS LIMIT bad' => 'Unsupported SHOW ERRORS statement.', + 'SHOW COUNT(*) ERRORS LIMIT 1' => 'Unsupported SHOW ERRORS statement.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW diagnostics statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL SHOW PROCESSLIST uses native activity catalogs. + */ + public function test_real_pgsql_show_processlist_uses_postgresql_activity_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL SHOW PROCESSLIST catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $backend_pid = (string) $pdo->query( 'SELECT pg_catalog.pg_backend_pid()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $backend_sql = array(); + + $rows = $driver->query( 'SHOW FULL PROCESSLIST WHERE Id = ' . $backend_pid ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL PROCESSLIST WHERE Id', + $backend_sql + ); + $this->assertCount( 1, $rows ); + $this->assertSame( array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $current_row = $rows[0]; + $this->assertSame( $backend_pid, $this->get_row_value( $current_row, 'Id' ) ); + $this->assertSame( $database_name, $this->get_row_value( $current_row, 'db' ) ); + $this->assertSame( 'Query', $this->get_row_value( $current_row, 'Command' ) ); + $this->assertRegExp( '/^[0-9]+$/', (string) $this->get_row_value( $current_row, 'Time' ) ); + $this->assertStringContainsString( 'pg_catalog.pg_stat_activity', (string) $this->get_row_value( $current_row, 'Info' ) ); + + $filtered_rows = $driver->query( "SHOW PROCESSLIST WHERE Id = {$backend_pid} AND Command = 'Query' LIMIT 1" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PROCESSLIST WHERE Id and Command LIMIT', + $backend_sql + ); + $this->assertCount( 1, $filtered_rows ); + $this->assertSame( $backend_pid, $this->get_row_value( $filtered_rows[0], 'Id' ) ); + $this->assertSame( '1', $driver->query( 'SELECT FOUND_ROWS()' )[0]->{'FOUND_ROWS()'} ); + + $assoc_rows = $driver->query( 'SHOW FULL PROCESSLIST WHERE Id = ' . $backend_pid, PDO::FETCH_ASSOC ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL PROCESSLIST assoc', + $backend_sql + ); + $this->assertCount( 1, $assoc_rows ); + $this->assertSame( array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), array_keys( $assoc_rows[0] ) ); + $this->assertSame( $backend_pid, $assoc_rows[0]['Id'] ); + + $direct_rows = $driver->query( + 'SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO + FROM information_schema.processlist + WHERE ID = ' . $backend_pid + ); + $direct_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.processlist by ID', + $backend_sql + ); + $this->assertCount( 1, $direct_rows ); + $this->assertSame( array( 'ID', 'USER', 'HOST', 'DB', 'COMMAND', 'TIME', 'STATE', 'INFO' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $direct_row = $direct_rows[0]; + $this->assertSame( $backend_pid, $this->get_row_value( $direct_row, 'ID' ) ); + $this->assertSame( $database_name, $this->get_row_value( $direct_row, 'DB' ) ); + $this->assertSame( 'Query', $this->get_row_value( $direct_row, 'COMMAND' ) ); + $this->assertRegExp( '/^[0-9]+$/', (string) $this->get_row_value( $direct_row, 'TIME' ) ); + $this->assertStringContainsString( 'pg_catalog.pg_stat_activity', (string) $this->get_row_value( $direct_row, 'INFO' ) ); + $this->assertStringContainsString( 'a.pid AS "ID"', $direct_sql ); + $this->assertStringContainsString( 'COALESCE(a.usename, CURRENT_USER) AS "USER"', $direct_sql ); + $this->assertStringContainsString( 'COALESCE(a.query, \'\') AS "INFO"', $direct_sql ); + $this->assertStringContainsString( 'WHERE a.datname IS NULL OR a.datname = current_database()', $direct_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $direct_sql ); + + $all_backend_sql = implode( "\n", $backend_sql ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_stat_activity a', $all_backend_sql ); + $this->assertStringContainsString( 'CASE WHEN a.state = \'idle\' THEN \'Sleep\' ELSE \'Query\' END AS "COMMAND"', $all_backend_sql ); + $this->assertStringContainsString( 'ORDER BY p."ID"', $all_backend_sql ); + $this->assertStringNotContainsString( '\'root\' AS "USER"', $all_backend_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests real PostgreSQL direct information_schema.KEYWORDS uses native keyword catalogs. + */ + public function test_real_pgsql_direct_information_schema_keywords_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema keywords test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $backend_sql = array(); + + $rows = $driver->query( + "SELECT WORD, RESERVED + FROM information_schema.keywords + WHERE WORD IN ('SELECT', 'FROM', 'WHERE') + ORDER BY WORD" + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.keywords real PostgreSQL', + $backend_sql + ); + + $this->assertGreaterThanOrEqual( 3, count( $rows ) ); + $this->assertSame( array( 'WORD', 'RESERVED' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $keywords = array(); + foreach ( $rows as $row ) { + $keywords[ $this->get_row_value( $row, 'WORD' ) ] = (string) $this->get_row_value( $row, 'RESERVED' ); + } + + $this->assertSame( '1', $keywords['FROM'] ?? null ); + $this->assertSame( '1', $keywords['SELECT'] ?? null ); + $this->assertSame( '1', $keywords['WHERE'] ?? null ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_get_keywords() k', $sql ); + $this->assertStringContainsString( 'UPPER(k.word) AS "WORD"', $sql ); + $this->assertStringContainsString( 'CASE WHEN k.catcode = \'R\' THEN 1 ELSE 0 END AS "RESERVED"', $sql ); + $this->assertStringNotContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( "'SELECT' AS \"WORD\"", $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests unsupported SHOW PROCESSLIST clauses fail before backend execution. + */ + public function test_unsupported_show_processlist_clauses_fail_closed(): void { + $queries = array( + 'SHOW GLOBAL PROCESSLIST', + "SHOW PROCESSLIST WHERE Unknown = 'Query'", + 'SHOW PROCESSLIST LIMIT bad', + "SHOW PROCESSLIST WHERE Command = 'Query' ORDER BY Id", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW PROCESSLIST statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW PROCESSLIST statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW CHARACTER SET returns MySQL-shaped static character set rows. + */ + public function test_show_character_set_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW CHARACTER SET' ); + + $this->assertEquals( + array( + (object) array( + 'Charset' => 'binary', + 'Description' => 'Binary pseudo charset', + 'Default collation' => 'binary', + 'Maxlen' => '1', + ), + (object) array( + 'Charset' => 'utf8', + 'Description' => 'UTF-8 Unicode', + 'Default collation' => 'utf8_general_ci', + 'Maxlen' => '3', + ), + (object) array( + 'Charset' => 'utf8mb4', + 'Description' => 'UTF-8 Unicode', + 'Default collation' => 'utf8mb4_0900_ai_ci', + 'Maxlen' => '4', + ), + ), + $rows + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'Charset', 'Description', 'Default collation', 'Maxlen' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $charset_rows = $driver->query( 'SHOW CHARSET' ); + $this->assertEquals( $rows, $charset_rows ); + + $like_rows = $driver->query( "SHOW CHARACTER SET LIKE 'utf8%'" ); + $this->assertSame( + array( 'utf8', 'utf8mb4' ), + array_map( + static function ( $row ): string { + return $row->Charset; + }, + $like_rows + ) + ); + + $where_rows = $driver->query( "SHOW CHARACTER SET WHERE Charset = 'utf8mb4'" ); + $this->assertSame( array( 'utf8mb4' ), array( $where_rows[0]->Charset ) ); + + $collation_rows = $driver->query( "SHOW CHARACTER SET WHERE `Default collation` = 'binary'" ); + $this->assertSame( array( 'binary' ), array( $collation_rows[0]->Charset ) ); + + $where_expression_rows = $driver->query( "SHOW CHARACTER SET WHERE Charset <> 'binary' AND Maxlen >= 4" ); + $this->assertSame( array( 'utf8mb4' ), array( $where_expression_rows[0]->Charset ) ); + + $literal_left_rows = $driver->query( "SHOW CHARACTER SET WHERE 'Charset' = 'utf8mb4'" ); + $this->assertSame( array(), $literal_left_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW COLLATION returns MySQL-shaped static collation rows. + */ + public function test_show_collation_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW COLLATION' ); + + $this->assertSame( + array( + 'binary', + 'utf8_bin', + 'utf8_general_ci', + 'utf8_unicode_ci', + 'utf8mb4_bin', + 'utf8mb4_unicode_ci', + 'utf8mb4_0900_ai_ci', + ), + array_map( + static function ( $row ) { + return $row->Collation; + }, + $rows + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $like_rows = $driver->query( "SHOW COLLATION LIKE 'utf8%'" ); + $this->assertCount( 6, $like_rows ); + $this->assertSame( 'utf8_bin', $like_rows[0]->Collation ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $like_rows[5]->Collation ); + + $where_rows = $driver->query( "SHOW COLLATION WHERE Collation = 'utf8_bin'" ); + $this->assertSame( array( 'utf8_bin' ), array( $where_rows[0]->Collation ) ); + + $case_insensitive_charset_rows = $driver->query( "SHOW COLLATION WHERE Charset = 'UTF8'" ); + $this->assertSame( + array( 'utf8_bin', 'utf8_general_ci', 'utf8_unicode_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $case_insensitive_charset_rows + ) + ); + + $binary_charset_rows = $driver->query( "SHOW COLLATION WHERE BINARY Charset = 'UTF8'" ); + $this->assertSame( array(), $binary_charset_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $where_expression_rows = $driver->query( "SHOW COLLATION WHERE Collation LIKE 'utf8%' AND Charset = 'utf8'" ); + $this->assertSame( + array( 'utf8_bin', 'utf8_general_ci', 'utf8_unicode_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $where_expression_rows + ) + ); + + $not_equal_rows = $driver->query( "SHOW COLLATION WHERE Collation <> 'binary' AND Charset = 'utf8mb4'" ); + $this->assertSame( + array( 'utf8mb4_bin', 'utf8mb4_unicode_ci', 'utf8mb4_0900_ai_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $not_equal_rows + ) + ); + + $literal_left_rows = $driver->query( "SHOW COLLATION WHERE 'Collation' = 'utf8_bin'" ); + $this->assertSame( array(), $literal_left_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests malformed SHOW CREATE DATABASE clauses fail before backend execution. + */ + public function test_unsupported_show_create_database_clauses_fail_closed(): void { + $queries = array( + 'SHOW CREATE DATABASE', + 'SHOW CREATE DATABASE wptests LIKE "wp%"', + 'SHOW CREATE DATABASE IF EXISTS wptests', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW CREATE DATABASE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW CREATE DATABASE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW ENGINES returns MySQL-shaped static storage engine rows. + */ + public function test_show_engines_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW ENGINES' ); + + $this->assertSame( + array( 'InnoDB', 'MEMORY', 'MyISAM' ), + array_map( + static function ( $row ): string { + return $row->Engine; + }, + $rows + ) + ); + $this->assertSame( 'DEFAULT', $rows[0]->Support ); + $this->assertSame( 'YES', $rows[0]->Transactions ); + $this->assertSame( array( 'Engine', 'Support', 'Comment', 'Transactions', 'XA', 'Savepoints' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $default_rows = $driver->query( "SHOW STORAGE ENGINES WHERE Support = 'DEFAULT'" ); + $this->assertSame( array( 'InnoDB' ), array( $default_rows[0]->Engine ) ); + + $like_rows = $driver->query( "SHOW ENGINES LIKE 'M%'" ); + $this->assertSame( + array( 'MEMORY', 'MyISAM' ), + array_map( + static function ( $row ): string { + return $row->Engine; + }, + $like_rows + ) + ); + + $transaction_rows = $driver->query( "SHOW ENGINES WHERE Transactions = 'YES' AND Savepoints = 'YES'" ); + $this->assertSame( array( 'InnoDB' ), array( $transaction_rows[0]->Engine ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW ENGINES clauses fail before backend execution. + */ + public function test_unsupported_show_engines_clauses_fail_closed(): void { + $queries = array( + 'SHOW ENGINES LIMIT 1', + 'SHOW ENGINES LIKE Engine', + "SHOW ENGINES WHERE Unknown = 'x'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW ENGINES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW ENGINES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL direct information_schema.ENGINES uses static compatibility rows. + */ + public function test_real_pgsql_direct_information_schema_engines_uses_static_rows(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema engines test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $backend_sql = array(); + + $engines = $driver->query( + "SELECT ENGINE, SUPPORT, COMMENT, TRANSACTIONS, XA, SAVEPOINTS + FROM information_schema.engines + WHERE ENGINE = 'InnoDB'" + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.engines real PostgreSQL', + $backend_sql + ); + + $this->assertCount( 1, $engines ); + $this->assertSame( + array( 'ENGINE', 'SUPPORT', 'COMMENT', 'TRANSACTIONS', 'XA', 'SAVEPOINTS' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $engine = $engines[0]; + $this->assertSame( 'InnoDB', $this->get_row_value( $engine, 'ENGINE' ) ); + $this->assertSame( 'DEFAULT', $this->get_row_value( $engine, 'SUPPORT' ) ); + $this->assertSame( 'Supports transactions, row-level locking, and foreign keys', $this->get_row_value( $engine, 'COMMENT' ) ); + $this->assertSame( 'YES', $this->get_row_value( $engine, 'TRANSACTIONS' ) ); + $this->assertSame( 'YES', $this->get_row_value( $engine, 'XA' ) ); + $this->assertSame( 'YES', $this->get_row_value( $engine, 'SAVEPOINTS' ) ); + $this->assertStringContainsString( '\'InnoDB\' AS "ENGINE"', $sql ); + $this->assertStringContainsString( '\'DEFAULT\' AS "SUPPORT"', $sql ); + $this->assertStringContainsString( '\'Supports transactions, row-level locking, and foreign keys\' AS "COMMENT"', $sql ); + $this->assertStringContainsString( '\'YES\' AS "TRANSACTIONS"', $sql ); + $this->assertStringContainsString( '\'YES\' AS "XA"', $sql ); + $this->assertStringContainsString( '\'YES\' AS "SAVEPOINTS"', $sql ); + $this->assertStringContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_available_extensions', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_stat_activity', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_get_keywords()', $sql ); + + $like_engines = $driver->query( + "SELECT ENGINE + FROM information_schema.engines + WHERE ENGINE LIKE 'M%' + ORDER BY ENGINE" + ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.engines real PostgreSQL LIKE', + $backend_sql + ); + $this->assertSame( + array( 'MEMORY', 'MyISAM' ), + array_map( + static function ( $row ): string { + return $row->ENGINE; + }, + $like_engines + ) + ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests unsupported SHOW PLUGINS clauses fail before backend execution. + */ + public function test_unsupported_show_plugins_clauses_fail_closed(): void { + $queries = array( + 'SHOW PLUGINS LIMIT 1', + 'SHOW PLUGINS LIKE Name', + "SHOW PLUGINS WHERE Unknown = 'x'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW PLUGINS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW PLUGINS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL SHOW FUNCTION/PROCEDURE STATUS returns empty MySQL-shaped rows. + */ + public function test_real_pgsql_show_routine_status_returns_empty_mysql_shaped_rows(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL empty SHOW routine status test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_procedures_with_prefix( $pdo, 'task903_empty' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task903_empty' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $function_like = $driver->get_connection()->quote( 'task903_empty_' . $suffix . '_function_%' ); + $procedure_like = $driver->get_connection()->quote( 'task903_empty_' . $suffix . '_procedure_%' ); + $columns = array( + 'Db', + 'Name', + 'Type', + 'Definer', + 'Modified', + 'Created', + 'Security_type', + 'Comment', + 'character_set_client', + 'collation_connection', + 'Database Collation', + ); + $backend_sql = array(); + + try { + $function_rows = $driver->query( 'SHOW FUNCTION STATUS LIKE ' . $function_like ); + $function_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'empty SHOW FUNCTION STATUS LIKE', + $backend_sql + ); + + $this->assertSame( array(), $function_rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assert_show_routine_status_catalog_query_shape( $function_queries[0], 'FUNCTION' ); + + $procedure_rows = $driver->query( 'SHOW PROCEDURE STATUS LIKE ' . $procedure_like ); + $procedure_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'empty SHOW PROCEDURE STATUS LIKE', + $backend_sql + ); + + $this->assertSame( array(), $procedure_rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assert_show_routine_status_catalog_query_shape( $procedure_queries[0], 'PROCEDURE' ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_procedures_with_prefix( $pdo, 'task903_empty' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task903_empty' ); + $this->assertSame( array(), $this->get_public_pgsql_procedures_with_prefix( $pdo, 'task903_empty' ) ); + $this->assertSame( array(), $this->get_public_pgsql_functions_with_prefix( $pdo, 'task903_empty' ) ); + } + } + + /** + * Tests real PostgreSQL SHOW FUNCTION/PROCEDURE STATUS uses information_schema routines. + */ + public function test_real_pgsql_show_routine_status_uses_postgresql_information_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL SHOW routine status test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_procedures_with_prefix( $pdo, 'task903' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task903' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $function_name = 'task903_' . $suffix . '_slugify'; + $procedure_name = 'task903_' . $suffix . '_recount_terms'; + $database_literal = $driver->get_connection()->quote( $database_name ); + $function_literal = $driver->get_connection()->quote( $function_name ); + $procedure_like = $driver->get_connection()->quote( 'task903_' . $suffix . '_%recount%' ); + $quote_identifier = static function ( string $identifier ): string { + return WP_PostgreSQL_Connection::quote_identifier_value( $identifier ); + }; + $routine_columns = array( + 'Db', + 'Name', + 'Type', + 'Definer', + 'Modified', + 'Created', + 'Security_type', + 'Comment', + 'character_set_client', + 'collation_connection', + 'Database Collation', + ); + $backend_sql = array(); + + try { + $pdo->exec( + 'CREATE FUNCTION ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $function_name ) + . '(input text) RETURNS text LANGUAGE sql SECURITY DEFINER AS $$ + SELECT lower(input); +$$' + ); + $pdo->exec( + 'CREATE PROCEDURE ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $procedure_name ) + . '() LANGUAGE plpgsql SECURITY INVOKER AS $$ +BEGIN +END; +$$' + ); + + $function_rows = $driver->query( + "SHOW FUNCTION STATUS WHERE Db = {$database_literal} AND Name = {$function_literal}" + ); + $function_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FUNCTION STATUS exact function', + $backend_sql + ); + $this->assertCount( 1, $function_rows ); + $this->assertSame( $routine_columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( $database_name, $this->get_row_value( $function_rows[0], 'Db' ) ); + $this->assertSame( $function_name, $this->get_row_value( $function_rows[0], 'Name' ) ); + $this->assertSame( 'FUNCTION', $this->get_row_value( $function_rows[0], 'Type' ) ); + $this->assertIsString( $this->get_row_value( $function_rows[0], 'Definer' ) ); + $this->assertContains( $this->get_row_value( $function_rows[0], 'Security_type' ), array( 'DEFINER', 'INVOKER' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $function_rows[0], 'character_set_client' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $function_rows[0], 'collation_connection' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $function_rows[0], 'Database Collation' ) ); + $this->assert_show_routine_status_catalog_query_shape( $function_queries[0], 'FUNCTION' ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $procedure_rows = $driver->query( 'SHOW PROCEDURE STATUS LIKE ' . $procedure_like ); + $procedure_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW PROCEDURE STATUS LIKE procedure', + $backend_sql + ); + $this->assertCount( 1, $procedure_rows ); + $this->assertSame( $procedure_name, $this->get_row_value( $procedure_rows[0], 'Name' ) ); + $this->assertSame( 'PROCEDURE', $this->get_row_value( $procedure_rows[0], 'Type' ) ); + $this->assertContains( $this->get_row_value( $procedure_rows[0], 'Security_type' ), array( 'DEFINER', 'INVOKER' ) ); + $this->assert_show_routine_status_catalog_query_shape( $procedure_queries[0], 'PROCEDURE' ); + + $assoc_rows = $driver->query( "SHOW FUNCTION STATUS WHERE BINARY Name = {$function_literal}", PDO::FETCH_ASSOC ); + $assoc_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FUNCTION STATUS assoc function', + $backend_sql + ); + $this->assertCount( 1, $assoc_rows ); + $this->assertSame( $routine_columns, array_keys( $assoc_rows[0] ) ); + $this->assertSame( $function_name, $assoc_rows[0]['Name'] ); + $this->assertSame( 'FUNCTION', $assoc_rows[0]['Type'] ); + $this->assert_show_routine_status_catalog_query_shape( $assoc_queries[0], 'FUNCTION' ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_procedures_with_prefix( $pdo, 'task903' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task903' ); + $this->assertSame( array(), $this->get_public_pgsql_procedures_with_prefix( $pdo, 'task903' ) ); + $this->assertSame( array(), $this->get_public_pgsql_functions_with_prefix( $pdo, 'task903' ) ); + } + } + + /** + * Tests unsupported SHOW FUNCTION/PROCEDURE STATUS clauses fail before backend execution. + */ + public function test_unsupported_show_routine_status_clauses_fail_closed(): void { + $cases = array( + 'SHOW FUNCTION STATUS LIMIT 1' => 'Unsupported SHOW FUNCTION STATUS statement.', + 'SHOW FUNCTION STATUS LIKE Name' => 'Unsupported SHOW FUNCTION STATUS statement.', + "SHOW FUNCTION STATUS WHERE Bogus = 'x'" => 'Unsupported SHOW FUNCTION STATUS statement.', + 'SHOW PROCEDURE STATUS FROM wptests' => 'Unsupported SHOW PROCEDURE STATUS statement.', + 'SHOW PROCEDURE STATUS LIMIT 1' => 'Unsupported SHOW PROCEDURE STATUS statement.', + ); + + foreach ( $cases as $query => $message ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW routine status statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests USE accepts main database identifiers without backend execution. + */ + public function test_use_statement_accepts_main_database_identifiers_without_backend_execution(): void { + $queries = array( + 'USE wptests', + 'USE `wptests`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'}, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( 'DATABASE()', $driver->get_last_column_meta()[0]['name'], $query ); + } + } + + /** + * Tests real PostgreSQL USE accepts non-public catalog schemas. + */ + public function test_real_pgsql_use_statement_accepts_non_public_catalog_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL USE catalog schema test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task971_' . $suffix; + $table_name = 'plugin_options'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $table_sql = $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $table_column = 'Tables_in_' . $schema_name; + $backend_sql = array(); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task971' ); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( + 'CREATE TABLE ' . $table_sql . ' ( + plugin_id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + option_name text NOT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'USE ' . $schema_name ) ); + $use_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL USE non-public schema', + $backend_sql + ); + $this->assertCount( 1, $use_queries ); + $this->assertStringContainsString( 'FROM information_schema.schemata s', $use_queries[0]['sql'] ); + $this->assertSame( array( $schema_name ), $use_queries[0]['params'] ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( $schema_name, $database[0]->{'DATABASE()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $tables = $driver->query( 'SHOW TABLES' ); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL SHOW TABLES after USE non-public schema', + $backend_sql + ); + + $this->assertCount( 1, $tables ); + $this->assertSame( $table_column, $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( $table_name, $tables[0]->{$table_column} ); + $this->assertSame( array( $schema_name ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ' . $table_name ); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL SHOW COLUMNS after USE non-public schema', + $backend_sql + ); + + $this->assertCount( 2, $columns ); + $plugin_id = $this->find_row_by_value( $columns, 'Field', 'plugin_id' ); + $this->assertSame( 'NO', $this->get_row_value( $plugin_id, 'Null' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $plugin_id, 'Key' ) ); + $this->assertSame( array( $schema_name, $table_name ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $this->assertSame( 0, $driver->query( 'USE public' ) ); + $database = $driver->query( 'SELECT DATABASE()' ); + $this->assertSame( $database_name, $database[0]->{'DATABASE()'} ); + + try { + $driver->query( 'USE pg_catalog' ); + $this->fail( 'Expected internal PostgreSQL schema USE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported USE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $pdo->exec( 'SET search_path TO public' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task971' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task971' ) ); + } + } + + /** + * Tests internal PostgreSQL USE schema names fail before backend execution. + */ + public function test_use_statement_rejects_internal_postgresql_schema_before_backend_execution(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'USE pg_catalog' ); + $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 unsupported USE information_schema table reads fail closed. + */ + public function test_use_statement_information_schema_unsupported_table_reads_fail_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $queries = array( + 'SELECT * FROM wptests_options', + 'WITH q AS (SELECT * FROM wptests_options) SELECT * FROM q', + 'EXPLAIN SELECT * FROM wptests_options', + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema table read to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW-backed information_schema relations are available to direct metadata queries. + */ + public function test_direct_information_schema_static_relations_expose_show_backed_metadata(): void { + $driver = $this->create_driver(); + + $engines = $driver->query( + "SELECT ENGINE, SUPPORT, TRANSACTIONS + FROM information_schema.engines + WHERE ENGINE = 'InnoDB'" + ); + + $this->assertEquals( + array( + (object) array( + 'ENGINE' => 'InnoDB', + 'SUPPORT' => 'DEFAULT', + 'TRANSACTIONS' => 'YES', + ), + ), + $engines + ); + + $driver->query( 'USE information_schema' ); + $engine_columns = $driver->query( "SHOW COLUMNS FROM engines WHERE Field = 'ENGINE'" ); + + $this->assertEquals( + array( + (object) array( + 'Field' => 'ENGINE', + 'Type' => 'varchar(512)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ), + $engine_columns + ); + } + + /** + * Tests real PostgreSQL information_schema.USER_PRIVILEGES reads database ACLs. + */ + public function test_real_pgsql_direct_information_schema_user_privileges_uses_database_acls(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema user privileges test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $backend_sql = array(); + + $rows = $driver->query( + "SELECT GRANTEE, TABLE_CATALOG, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.user_privileges + WHERE TABLE_CATALOG = 'def' + ORDER BY GRANTEE, PRIVILEGE_TYPE" + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.user_privileges real PostgreSQL', + $backend_sql + ); + + $this->assertNotEmpty( $rows ); + $this->assertSame( + array( 'GRANTEE', 'TABLE_CATALOG', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + foreach ( $rows as $row ) { + $this->assertStringContainsString( '@\'%\'', (string) $this->get_row_value( $row, 'GRANTEE' ) ); + $this->assertSame( 'def', $this->get_row_value( $row, 'TABLE_CATALOG' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ); + $this->assertContains( $this->get_row_value( $row, 'IS_GRANTABLE' ), array( 'YES', 'NO' ) ); + } + + $this->assertStringContainsString( 'FROM pg_catalog.pg_database d', $sql ); + $this->assertStringContainsString( 'pg_catalog.aclexplode(COALESCE(d.datacl, pg_catalog.acldefault(\'d\', d.datdba))) acl', $sql ); + $this->assertStringContainsString( 'LEFT JOIN pg_catalog.pg_roles grantee_role', $sql ); + $this->assertStringContainsString( 'pg_catalog.quote_literal(CASE WHEN acl.grantee = 0 THEN \'PUBLIC\' ELSE grantee_role.rolname END) || \'@\'\'%\'\'\' AS "GRANTEE"', $sql ); + $this->assertStringContainsString( '\'def\' AS "TABLE_CATALOG"', $sql ); + $this->assertStringContainsString( 'acl.privilege_type AS "PRIVILEGE_TYPE"', $sql ); + $this->assertStringContainsString( 'CASE WHEN acl.is_grantable THEN \'YES\' ELSE \'NO\' END AS "IS_GRANTABLE"', $sql ); + $this->assertStringContainsString( 'WHERE d.datname = current_database()', $sql ); + $this->assertStringNotContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests real PostgreSQL information_schema.SCHEMA_PRIVILEGES reads schema ACLs. + */ + public function test_real_pgsql_direct_information_schema_schema_privileges_uses_schema_acls(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema schema privileges test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task980_' . $suffix; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $backend_sql = array(); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task980' ); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( 'GRANT USAGE ON SCHEMA ' . $schema_sql . ' TO PUBLIC' ); + + $rows = $driver->query( + 'SELECT GRANTEE, TABLE_CATALOG, TABLE_SCHEMA, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.schema_privileges + WHERE TABLE_SCHEMA = ' . $driver->get_connection()->quote( $schema_name ) . ' + ORDER BY GRANTEE, PRIVILEGE_TYPE' + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.schema_privileges real PostgreSQL', + $backend_sql + ); + + $this->assertNotEmpty( $rows ); + $this->assertSame( + array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $public_usage_row = null; + foreach ( $rows as $row ) { + $this->assertStringContainsString( '@\'%\'', (string) $this->get_row_value( $row, 'GRANTEE' ) ); + $this->assertSame( 'def', $this->get_row_value( $row, 'TABLE_CATALOG' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $row, 'TABLE_SCHEMA' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ); + $this->assertContains( $this->get_row_value( $row, 'IS_GRANTABLE' ), array( 'YES', 'NO' ) ); + + if ( + false !== strpos( (string) $this->get_row_value( $row, 'GRANTEE' ), 'PUBLIC' ) + && 'USAGE' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) + ) { + $public_usage_row = $row; + } + } + $this->assertNotNull( $public_usage_row ); + + $this->assertStringContainsString( 'FROM pg_catalog.pg_namespace n', $sql ); + $this->assertStringContainsString( 'pg_catalog.aclexplode(COALESCE(n.nspacl, pg_catalog.acldefault(\'n\', n.nspowner))) acl', $sql ); + $this->assertStringContainsString( 'LEFT JOIN pg_catalog.pg_roles grantee_role', $sql ); + $this->assertStringContainsString( 'pg_catalog.quote_literal(CASE WHEN acl.grantee = 0 THEN \'PUBLIC\' ELSE grantee_role.rolname END) || \'@\'\'%\'\'\' AS "GRANTEE"', $sql ); + $this->assertStringContainsString( '\'def\' AS "TABLE_CATALOG"', $sql ); + $this->assertStringContainsString( 'CASE WHEN n.nspname = \'public\' THEN', $sql ); + $this->assertStringContainsString( 'acl.privilege_type AS "PRIVILEGE_TYPE"', $sql ); + $this->assertStringContainsString( 'CASE WHEN acl.is_grantable THEN \'YES\' ELSE \'NO\' END AS "IS_GRANTABLE"', $sql ); + $this->assertStringContainsString( 'n.nspname !~ \'^(pg_|information_schema$|pg_catalog$)\'', $sql ); + $this->assertStringNotContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task980' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task980' ) ); + } + } + + /** + * Tests real PostgreSQL information_schema table and column privileges use native relations. + */ + public function test_real_pgsql_direct_information_schema_table_and_column_privileges_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema table and column privileges test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $schema_name = 'task982_' . $suffix; + $table_name = 'privilege_target'; + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $table_sql = $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $backend_sql = array(); + + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task982' ); + + try { + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( + 'CREATE TABLE ' . $table_sql . ' ( + id integer NOT NULL, + sample_value text NOT NULL + )' + ); + $pdo->exec( 'GRANT USAGE ON SCHEMA ' . $schema_sql . ' TO PUBLIC' ); + $pdo->exec( 'GRANT SELECT ON ' . $table_sql . ' TO PUBLIC' ); + $pdo->exec( 'GRANT UPDATE (sample_value) ON ' . $table_sql . ' TO PUBLIC' ); + + $table_rows = $driver->query( + 'SELECT GRANTEE, TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.table_privileges + WHERE TABLE_SCHEMA = ' . $driver->get_connection()->quote( $schema_name ) . ' + AND TABLE_NAME = ' . $driver->get_connection()->quote( $table_name ) . ' + ORDER BY GRANTEE, PRIVILEGE_TYPE' + ); + $table_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.table_privileges real PostgreSQL', + $backend_sql + ); + + $this->assertNotEmpty( $table_rows ); + $this->assertSame( + array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $public_select_table_row = null; + foreach ( $table_rows as $row ) { + $this->assertStringContainsString( '@\'%\'', (string) $this->get_row_value( $row, 'GRANTEE' ) ); + $this->assertSame( 'def', $this->get_row_value( $row, 'TABLE_CATALOG' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $row, 'TABLE_NAME' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ); + $this->assertContains( $this->get_row_value( $row, 'IS_GRANTABLE' ), array( 'YES', 'NO' ) ); + + if ( + false !== strpos( (string) $this->get_row_value( $row, 'GRANTEE' ), 'PUBLIC' ) + && 'SELECT' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) + ) { + $public_select_table_row = $row; + } + } + $this->assertNotNull( $public_select_table_row ); + + $this->assertStringContainsString( 'FROM information_schema.table_privileges tp', $table_sql ); + $this->assertStringContainsString( 'pg_catalog.quote_literal(tp.grantee) || \'@\'\'%\'\'\' AS "GRANTEE"', $table_sql ); + $this->assertStringContainsString( '\'def\' AS "TABLE_CATALOG"', $table_sql ); + $this->assertStringContainsString( 'tp.table_name AS "TABLE_NAME"', $table_sql ); + $this->assertStringContainsString( 'tp.privilege_type AS "PRIVILEGE_TYPE"', $table_sql ); + $this->assertStringContainsString( 'tp.is_grantable AS "IS_GRANTABLE"', $table_sql ); + $this->assertStringContainsString( 'CASE WHEN tp.table_schema = \'public\' THEN', $table_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $table_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $table_sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $table_sql ); + + $column_rows = $driver->query( + 'SELECT GRANTEE, TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.column_privileges + WHERE TABLE_SCHEMA = ' . $driver->get_connection()->quote( $schema_name ) . ' + AND TABLE_NAME = ' . $driver->get_connection()->quote( $table_name ) . ' + ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE_TYPE' + ); + $column_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.column_privileges real PostgreSQL', + $backend_sql + ); + + $this->assertNotEmpty( $column_rows ); + $this->assertSame( + array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'COLUMN_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $public_update_column_row = null; + foreach ( $column_rows as $row ) { + $this->assertStringContainsString( '@\'%\'', (string) $this->get_row_value( $row, 'GRANTEE' ) ); + $this->assertSame( 'def', $this->get_row_value( $row, 'TABLE_CATALOG' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $row, 'TABLE_NAME' ) ); + $this->assertContains( $this->get_row_value( $row, 'COLUMN_NAME' ), array( 'id', 'sample_value' ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ); + $this->assertContains( $this->get_row_value( $row, 'IS_GRANTABLE' ), array( 'YES', 'NO' ) ); + + if ( + false !== strpos( (string) $this->get_row_value( $row, 'GRANTEE' ), 'PUBLIC' ) + && 'sample_value' === $this->get_row_value( $row, 'COLUMN_NAME' ) + && 'UPDATE' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) + ) { + $public_update_column_row = $row; + } + } + $this->assertNotNull( $public_update_column_row ); + + $this->assertStringContainsString( 'FROM information_schema.column_privileges cp', $column_sql ); + $this->assertStringContainsString( 'pg_catalog.quote_literal(cp.grantee) || \'@\'\'%\'\'\' AS "GRANTEE"', $column_sql ); + $this->assertStringContainsString( '\'def\' AS "TABLE_CATALOG"', $column_sql ); + $this->assertStringContainsString( 'cp.table_name AS "TABLE_NAME"', $column_sql ); + $this->assertStringContainsString( 'cp.column_name AS "COLUMN_NAME"', $column_sql ); + $this->assertStringContainsString( 'cp.privilege_type AS "PRIVILEGE_TYPE"', $column_sql ); + $this->assertStringContainsString( 'cp.is_grantable AS "IS_GRANTABLE"', $column_sql ); + $this->assertStringContainsString( 'CASE WHEN cp.table_schema = \'public\' THEN', $column_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $column_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $column_sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $column_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task982' ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task982' ) ); + } + } + + /** + * Tests real PostgreSQL information_schema role membership and grants use native relations. + */ + public function test_real_pgsql_direct_information_schema_role_membership_and_grants_use_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema role membership and grants test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $is_superuser = $pdo->query( 'SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user' )->fetchColumn(); + if ( true !== $is_superuser && 't' !== $is_superuser && '1' !== (string) $is_superuser ) { + $this->markTestSkipped( 'The real PostgreSQL role membership and grants test requires a superuser test role.' ); + } + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $current_user = (string) $pdo->query( 'SELECT current_user' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $member_role = 'task987_' . $suffix . '_member'; + $admin_role = 'task987_' . $suffix . '_admin'; + $schema_name = 'task987_' . $suffix . '_schema'; + $table_name = 'grant_target'; + $function_name = 'task987_' . $suffix . '_function'; + $member_sql = WP_PostgreSQL_Connection::quote_identifier_value( $member_role ); + $admin_sql = WP_PostgreSQL_Connection::quote_identifier_value( $admin_role ); + $current_sql = WP_PostgreSQL_Connection::quote_identifier_value( $current_user ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema_name ); + $table_sql = $schema_sql . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $function_sql = WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) . '.' . WP_PostgreSQL_Connection::quote_identifier_value( $function_name ); + $backend_sql = array(); + + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task987' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task987' ); + $this->drop_pgsql_roles_with_prefix( $pdo, 'task987' ); + + try { + $pdo->exec( 'CREATE ROLE ' . $member_sql . ' NOLOGIN' ); + $pdo->exec( 'CREATE ROLE ' . $admin_sql . ' NOLOGIN' ); + $pdo->exec( 'GRANT ' . $member_sql . ' TO ' . $current_sql ); + $pdo->exec( 'GRANT ' . $admin_sql . ' TO ' . $current_sql . ' WITH ADMIN OPTION' ); + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( + 'CREATE TABLE ' . $table_sql . ' ( + id integer NOT NULL, + sample text NOT NULL + )' + ); + $pdo->exec( + 'CREATE FUNCTION ' . $function_sql . '(input_value integer) RETURNS integer LANGUAGE sql AS $$ + SELECT input_value; +$$' + ); + $pdo->exec( 'GRANT USAGE ON SCHEMA ' . $schema_sql . ' TO ' . $member_sql ); + $pdo->exec( 'GRANT SELECT ON ' . $table_sql . ' TO ' . $member_sql ); + $pdo->exec( 'GRANT UPDATE (sample) ON ' . $table_sql . ' TO ' . $member_sql ); + $pdo->exec( 'GRANT EXECUTE ON FUNCTION ' . $function_sql . '(integer) TO ' . $member_sql ); + + $applicable_rows = $driver->query( + 'SELECT `USER`, HOST, GRANTEE, GRANTEE_HOST, ROLE_NAME, ROLE_HOST, IS_GRANTABLE, IS_DEFAULT, IS_MANDATORY + FROM information_schema.applicable_roles + WHERE ROLE_NAME IN (' . $driver->get_connection()->quote( $member_role ) . ', ' . $driver->get_connection()->quote( $admin_role ) . ') + ORDER BY ROLE_NAME' + ); + $applicable_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.applicable_roles real PostgreSQL', + $backend_sql + ); + $this->assertCount( 2, $applicable_rows ); + $applicable_member_row = $this->find_row_by_value( $applicable_rows, 'ROLE_NAME', $member_role ); + $applicable_admin_row = $this->find_row_by_value( $applicable_rows, 'ROLE_NAME', $admin_role ); + $this->assertSame( $current_user, $this->get_row_value( $applicable_member_row, 'USER' ) ); + $this->assertSame( '%', $this->get_row_value( $applicable_member_row, 'HOST' ) ); + $this->assertSame( $current_user, $this->get_row_value( $applicable_member_row, 'GRANTEE' ) ); + $this->assertSame( '%', $this->get_row_value( $applicable_member_row, 'GRANTEE_HOST' ) ); + $this->assertSame( '%', $this->get_row_value( $applicable_member_row, 'ROLE_HOST' ) ); + $this->assertSame( 'NO', $this->get_row_value( $applicable_member_row, 'IS_GRANTABLE' ) ); + $this->assertSame( 'YES', $this->get_row_value( $applicable_admin_row, 'IS_GRANTABLE' ) ); + $this->assertSame( 'NO', $this->get_row_value( $applicable_member_row, 'IS_DEFAULT' ) ); + $this->assertSame( 'NO', $this->get_row_value( $applicable_member_row, 'IS_MANDATORY' ) ); + $this->assertStringContainsString( 'FROM information_schema.applicable_roles ar', $applicable_sql ); + $this->assertStringContainsString( 'ar.grantee AS "USER"', $applicable_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $applicable_sql ); + + $administrable_rows = $driver->query( + 'SELECT `USER`, HOST, GRANTEE, GRANTEE_HOST, ROLE_NAME, ROLE_HOST, IS_GRANTABLE, IS_DEFAULT, IS_MANDATORY + FROM information_schema.administrable_role_authorizations + WHERE ROLE_NAME = ' . $driver->get_connection()->quote( $admin_role ) + ); + $administrable_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.administrable_role_authorizations real PostgreSQL', + $backend_sql + ); + $this->assertCount( 1, $administrable_rows ); + $this->assertSame( $current_user, $this->get_row_value( $administrable_rows[0], 'USER' ) ); + $this->assertSame( '%', $this->get_row_value( $administrable_rows[0], 'HOST' ) ); + $this->assertSame( $admin_role, $this->get_row_value( $administrable_rows[0], 'ROLE_NAME' ) ); + $this->assertSame( 'YES', $this->get_row_value( $administrable_rows[0], 'IS_GRANTABLE' ) ); + $this->assertStringContainsString( 'FROM information_schema.administrable_role_authorizations ara', $administrable_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $administrable_sql ); + + $enabled_rows = $driver->query( + 'SELECT ROLE_NAME, ROLE_HOST, IS_DEFAULT, IS_MANDATORY + FROM information_schema.enabled_roles + WHERE ROLE_NAME IN (' . $driver->get_connection()->quote( $member_role ) . ', ' . $driver->get_connection()->quote( $admin_role ) . ') + ORDER BY ROLE_NAME' + ); + $enabled_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.enabled_roles real PostgreSQL', + $backend_sql + ); + $this->assertCount( 2, $enabled_rows ); + $enabled_member_row = $this->find_row_by_value( $enabled_rows, 'ROLE_NAME', $member_role ); + $this->assertSame( '%', $this->get_row_value( $enabled_member_row, 'ROLE_HOST' ) ); + $this->assertSame( 'NO', $this->get_row_value( $enabled_member_row, 'IS_DEFAULT' ) ); + $this->assertSame( 'NO', $this->get_row_value( $enabled_member_row, 'IS_MANDATORY' ) ); + $this->assertStringContainsString( 'FROM information_schema.enabled_roles er', $enabled_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $enabled_sql ); + + $table_rows = $driver->query( + 'SELECT GRANTOR, GRANTOR_HOST, GRANTEE, GRANTEE_HOST, TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.role_table_grants + WHERE TABLE_SCHEMA = ' . $driver->get_connection()->quote( $schema_name ) . ' + AND TABLE_NAME = ' . $driver->get_connection()->quote( $table_name ) . ' + ORDER BY GRANTEE, PRIVILEGE_TYPE' + ); + $table_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.role_table_grants real PostgreSQL', + $backend_sql + ); + $table_member_row = null; + foreach ( $table_rows as $row ) { + if ( $member_role === $this->get_row_value( $row, 'GRANTEE' ) && 'SELECT' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ) { + $table_member_row = $row; + break; + } + } + $this->assertNotNull( $table_member_row ); + $this->assertSame( $current_user, $this->get_row_value( $table_member_row, 'GRANTOR' ) ); + $this->assertSame( '%', $this->get_row_value( $table_member_row, 'GRANTOR_HOST' ) ); + $this->assertSame( '%', $this->get_row_value( $table_member_row, 'GRANTEE_HOST' ) ); + $this->assertSame( 'def', $this->get_row_value( $table_member_row, 'TABLE_CATALOG' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $table_member_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $table_member_row, 'TABLE_NAME' ) ); + $this->assertSame( 'NO', $this->get_row_value( $table_member_row, 'IS_GRANTABLE' ) ); + $this->assertStringContainsString( 'FROM information_schema.role_table_grants rtg', $table_sql ); + $this->assertStringContainsString( 'rtg.table_name AS "TABLE_NAME"', $table_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $table_sql ); + + $column_rows = $driver->query( + 'SELECT GRANTOR, GRANTOR_HOST, GRANTEE, GRANTEE_HOST, TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.role_column_grants + WHERE TABLE_SCHEMA = ' . $driver->get_connection()->quote( $schema_name ) . ' + AND TABLE_NAME = ' . $driver->get_connection()->quote( $table_name ) . ' + ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE_TYPE' + ); + $column_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.role_column_grants real PostgreSQL', + $backend_sql + ); + $column_member_row = null; + foreach ( $column_rows as $row ) { + if ( + $member_role === $this->get_row_value( $row, 'GRANTEE' ) + && 'sample' === $this->get_row_value( $row, 'COLUMN_NAME' ) + && 'UPDATE' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) + ) { + $column_member_row = $row; + break; + } + } + $this->assertNotNull( $column_member_row ); + $this->assertSame( $current_user, $this->get_row_value( $column_member_row, 'GRANTOR' ) ); + $this->assertSame( '%', $this->get_row_value( $column_member_row, 'GRANTOR_HOST' ) ); + $this->assertSame( '%', $this->get_row_value( $column_member_row, 'GRANTEE_HOST' ) ); + $this->assertSame( 'def', $this->get_row_value( $column_member_row, 'TABLE_CATALOG' ) ); + $this->assertSame( $schema_name, $this->get_row_value( $column_member_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $column_member_row, 'TABLE_NAME' ) ); + $this->assertSame( 'NO', $this->get_row_value( $column_member_row, 'IS_GRANTABLE' ) ); + $this->assertStringContainsString( 'FROM information_schema.role_column_grants rcg', $column_sql ); + $this->assertStringContainsString( 'rcg.column_name AS "COLUMN_NAME"', $column_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $column_sql ); + + $routine_rows = $driver->query( + 'SELECT GRANTOR, GRANTOR_HOST, GRANTEE, GRANTEE_HOST, SPECIFIC_SCHEMA, SPECIFIC_NAME, ROUTINE_SCHEMA, ROUTINE_NAME, PRIVILEGE_TYPE, IS_GRANTABLE + FROM information_schema.role_routine_grants + WHERE ROUTINE_NAME = ' . $driver->get_connection()->quote( $function_name ) . ' + ORDER BY GRANTEE, PRIVILEGE_TYPE' + ); + $routine_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.role_routine_grants real PostgreSQL', + $backend_sql + ); + $routine_member_row = null; + foreach ( $routine_rows as $row ) { + if ( $member_role === $this->get_row_value( $row, 'GRANTEE' ) && 'EXECUTE' === $this->get_row_value( $row, 'PRIVILEGE_TYPE' ) ) { + $routine_member_row = $row; + break; + } + } + $this->assertNotNull( $routine_member_row ); + $this->assertSame( $current_user, $this->get_row_value( $routine_member_row, 'GRANTOR' ) ); + $this->assertSame( '%', $this->get_row_value( $routine_member_row, 'GRANTOR_HOST' ) ); + $this->assertSame( '%', $this->get_row_value( $routine_member_row, 'GRANTEE_HOST' ) ); + $this->assertSame( $database_name, $this->get_row_value( $routine_member_row, 'SPECIFIC_SCHEMA' ) ); + $this->assertSame( $database_name, $this->get_row_value( $routine_member_row, 'ROUTINE_SCHEMA' ) ); + $this->assertSame( $function_name, $this->get_row_value( $routine_member_row, 'ROUTINE_NAME' ) ); + $this->assertSame( 'NO', $this->get_row_value( $routine_member_row, 'IS_GRANTABLE' ) ); + $this->assertStringContainsString( 'FROM information_schema.role_routine_grants rrg', $routine_sql ); + $this->assertStringContainsString( 'rrg.routine_name AS "ROUTINE_NAME"', $routine_sql ); + $this->assertStringNotContainsString( 'UNION ALL', $routine_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task987' ); + $this->drop_pgsql_schemas_with_prefix( $pdo, 'task987' ); + $this->drop_pgsql_roles_with_prefix( $pdo, 'task987' ); + $this->assertSame( array(), $this->get_public_pgsql_functions_with_prefix( $pdo, 'task987' ) ); + $this->assertSame( array(), $this->get_pgsql_schemas_with_prefix( $pdo, 'task987' ) ); + $this->assertSame( array(), $this->get_pgsql_roles_with_prefix( $pdo, 'task987' ) ); + } + } + + /** + * Tests real PostgreSQL information_schema.COLUMN_STATISTICS reads pg_stats. + */ + public function test_real_pgsql_direct_information_schema_column_statistics_uses_pg_stats(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema column statistics test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task965_' . $suffix; + $table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $backend_sql = array(); + + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task965' ); + + try { + $pdo->exec( + 'CREATE TABLE ' . $table_sql . ' ( + id integer NOT NULL, + sample_value text NOT NULL + )' + ); + + $insert = $pdo->prepare( 'INSERT INTO ' . $table_sql . ' (id, sample_value) VALUES (?, ?)' ); + for ( $i = 1; $i <= 200; ++$i ) { + $insert->execute( array( $i, 'sample-' . str_pad( (string) $i, 3, '0', STR_PAD_LEFT ) ) ); + } + + $pdo->exec( 'ANALYZE ' . $table_sql ); + + $rows = $driver->query( + 'SELECT schema_name, table_name, column_name, histogram + FROM information_schema.column_statistics + WHERE schema_name = DATABASE() + AND table_name = ' . $driver->get_connection()->quote( $table_name ) . ' + AND column_name = \'sample_value\' + ORDER BY column_name' + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.column_statistics real PostgreSQL', + $backend_sql + ); + + $this->assertGreaterThanOrEqual( 1, count( $rows ) ); + $this->assertSame( + array( 'SCHEMA_NAME', 'TABLE_NAME', 'COLUMN_NAME', 'HISTOGRAM' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $row = $this->find_row_by_value( $rows, 'COLUMN_NAME', 'sample_value' ); + $this->assertSame( $database_name, $this->get_row_value( $row, 'SCHEMA_NAME' ) ); + $this->assertSame( $table_name, $this->get_row_value( $row, 'TABLE_NAME' ) ); + + $histogram = json_decode( (string) $this->get_row_value( $row, 'HISTOGRAM' ), true ); + $this->assertIsArray( $histogram ); + $this->assertArrayHasKey( 'buckets', $histogram ); + $this->assertArrayHasKey( 'null-values', $histogram ); + $this->assertArrayHasKey( 'last-updated', $histogram ); + + $this->assertStringContainsString( 'FROM pg_catalog.pg_stats stats', $sql ); + $this->assertStringContainsString( 'stats.tablename AS "TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'stats.attname AS "COLUMN_NAME"', $sql ); + $this->assertStringContainsString( 'pg_catalog.json_build_object', $sql ); + $this->assertStringContainsString( 'pg_catalog.to_json(stats.histogram_bounds)', $sql ); + $this->assertStringNotContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task965' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task965' ) ); + } + } + + /** + * Tests MySQL-only diagnostic information_schema relations are empty and queryable. + */ + public function test_direct_information_schema_mysql_diagnostic_relations_are_empty_and_queryable(): void { + $relations = array( + 'optimizer_trace' => array( + 'query' => 'SELECT QUERY, TRACE, MISSING_BYTES_BEYOND_MAX_MEM_SIZE, INSUFFICIENT_PRIVILEGES FROM information_schema.optimizer_trace', + 'columns' => array( 'QUERY', 'TRACE', 'MISSING_BYTES_BEYOND_MAX_MEM_SIZE', 'INSUFFICIENT_PRIVILEGES' ), + ), + 'profiling' => array( + 'query' => 'SELECT QUERY_ID, STATE, DURATION, CPU_USER, SOURCE_LINE FROM information_schema.profiling', + 'columns' => array( 'QUERY_ID', 'STATE', 'DURATION', 'CPU_USER', 'SOURCE_LINE' ), + ), + ); + + foreach ( $relations as $relation => $assertions ) { + $driver = $this->create_driver(); + $rows = $driver->query( $assertions['query'] ); + + $this->assertSame( array(), $rows, $relation ); + $this->assertSame( + $assertions['columns'], + array_column( $driver->get_last_column_meta(), 'name' ), + $relation + ); + } + } + + /** + * Tests real PostgreSQL information_schema.TABLES joins tablespace catalog relations. + */ + public function test_real_pgsql_direct_information_schema_tables_tablespaces_join_uses_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema tables/tablespaces join test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $tablespace = $pdo->query( + 'SELECT spcname + FROM pg_catalog.pg_tablespace + ORDER BY spcname + LIMIT 1' + )->fetch( PDO::FETCH_ASSOC ); + $this->assertIsArray( $tablespace ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task970_' . $suffix; + $table_identifier = WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $tablespace_name = (string) $tablespace['spcname']; + $tablespace_literal = $driver->get_connection()->quote( $tablespace_name ); + $backend_sql = array(); + + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task970' ); + + try { + $pdo->exec( + 'CREATE TABLE ' . $table_identifier . ' ( + id integer NOT NULL, + sample_value text NOT NULL + )' + ); + + $rows = $driver->query( + 'SELECT t.TABLE_NAME, ts.TABLESPACE_NAME + FROM information_schema.tables AS t + JOIN information_schema.tablespaces AS ts ON TRUE + WHERE t.TABLE_SCHEMA = DATABASE() + AND t.TABLE_NAME = ' . $driver->get_connection()->quote( $table_name ) . ' + AND ts.TABLESPACE_NAME = ' . $tablespace_literal . ' + ORDER BY t.TABLE_NAME, ts.TABLESPACE_NAME' + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.tables/tablespaces join real PostgreSQL', + $backend_sql + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( 'TABLE_NAME', 'TABLESPACE_NAME' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( $table_name, $this->get_row_value( $rows[0], 'TABLE_NAME' ) ); + $this->assertSame( $tablespace_name, $this->get_row_value( $rows[0], 'TABLESPACE_NAME' ) ); + + $this->assertStringContainsString( 'FROM information_schema.tables t', $sql ); + $this->assertStringContainsString( 'FROM pg_catalog.pg_tablespace ts', $sql ); + $this->assertStringContainsString( 'ts.spcname AS "TABLESPACE_NAME"', $sql ); + $this->assertStringContainsString( 'AS "ts"', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task970' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task970' ) ); + } + } + + /** + * Tests remaining corpus-referenced information_schema relations are queryable. + */ + public function test_direct_information_schema_remaining_mysql_metadata_relations_are_queryable(): void { + $driver = $this->create_driver(); + + $applicability = $driver->query( + 'SELECT COLLATION_NAME, CHARACTER_SET_NAME + FROM information_schema.collation_character_set_applicability + ORDER BY COLLATION_NAME + LIMIT 1' + ); + $this->assertCount( 1, $applicability ); + $this->assertSame( + array( 'COLLATION_NAME', 'CHARACTER_SET_NAME' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $relations = array( + 'resource_groups' => array( + 'query' => 'SELECT * FROM information_schema.resource_groups', + 'columns' => array( 'RESOURCE_GROUP_NAME', 'RESOURCE_GROUP_TYPE', 'RESOURCE_GROUP_ENABLED', 'VCPU_IDS', 'THREAD_PRIORITY' ), + ), + 'user_attributes' => array( + 'query' => "SELECT USER, HOST, ATTRIBUTE FROM information_schema.user_attributes WHERE USER LIKE 'missing%'", + 'columns' => array( 'USER', 'HOST', 'ATTRIBUTE' ), + ), + ); + + foreach ( $relations as $relation => $assertions ) { + $driver = $this->create_driver(); + $rows = $driver->query( $assertions['query'] ); + + $this->assertSame( array(), $rows, $relation ); + $this->assertSame( + $assertions['columns'], + array_column( $driver->get_last_column_meta(), 'name' ), + $relation + ); + } + } + + /** + * Tests information_schema table administration handlers fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_administration_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'CHECK TABLE wptests_options' ); + $this->fail( 'Expected information_schema table administration to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests main database-qualified application table writes still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_main_database_qualified_writes(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'CREATE TABLE wptests.use_info_main_write (id INTEGER PRIMARY KEY, value TEXT)' ) ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests.use_info_main_write (id, value) VALUES (1, 'inserted')" ) ); + $this->assertSame( 1, $driver->query( "REPLACE INTO wptests.use_info_main_write (id, value) VALUES (2, 'replaced')" ) ); + $this->assertSame( 1, $driver->query( "UPDATE wptests.use_info_main_write SET value = 'updated' WHERE id = 1" ) ); + $this->assertSame( 1, $driver->query( 'DELETE FROM wptests.use_info_main_write WHERE id = 2' ) ); + + $this->assertSame( 1, $driver->query( 'ALTER TABLE wptests.use_info_main_write ADD COLUMN extra VARCHAR(20)' ) ); + $this->assertSame( 0, $driver->query( 'CREATE INDEX idx_use_info_main_write_value ON wptests.use_info_main_write (value)' ) ); + + $check = $driver->query( 'CHECK TABLE wptests.use_info_main_write' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.use_info_main_write', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $check + ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES wptests.use_info_main_write READ' ) ); + $this->assertSame( 0, $driver->query( 'UNLOCK TABLES' ) ); + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_use_info_main_write_value ON wptests.use_info_main_write' ) ); + + $driver->query( 'USE wptests' ); + $rows = $driver->query( 'SELECT id, value, extra FROM use_info_main_write' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'updated', + 'extra' => null, + ), + ), + $rows + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'DROP TABLE wptests.use_info_main_write' ) ); + } + + /** + * Tests simple main database-qualified SELECT reads still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_simple_main_database_qualified_selects(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( 'CREATE TABLE use_info_main_read_two (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( "INSERT INTO use_info_main_read (id, label) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( "INSERT INTO use_info_main_read_two (id, label) VALUES (1, 'first'), (2, 'second')" ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + 'SELECT label + FROM wptests.use_info_main_read + WHERE id = 1 + ORDER BY label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT label FROM use_info_main_read WHERE id = 1 ORDER BY label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label + FROM wptests.use_info_main_read AS r + WHERE r.id = 2 + ORDER BY r.label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label FROM use_info_main_read AS "r" WHERE r.id = 2 ORDER BY r.label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label + FROM wptests.use_info_main_read r + WHERE r.id = 1 + ORDER BY r.label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label FROM use_info_main_read AS "r" WHERE r.id = 1 ORDER BY r.label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label, r2.label AS two_label + FROM wptests.use_info_main_read AS r + JOIN wptests.use_info_main_read_two AS r2 ON r2.id = r.id + WHERE r.id = 2 + ORDER BY r2.label' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + 'two_label' => 'second', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label, r2.label AS two_label FROM use_info_main_read AS r JOIN use_info_main_read_two AS r2 ON r2.id = r.id WHERE r.id = 2 ORDER BY r2.label', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests nested main database-qualified SELECT reads still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_nested_main_database_qualified_selects(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( 'CREATE TABLE use_info_main_read_two (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( "INSERT INTO use_info_main_read (id, label) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( "INSERT INTO use_info_main_read_two (id, label) VALUES (1, 'first'), (2, 'second')" ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $derived_rows = $driver->query( + 'SELECT r.label + FROM ( + SELECT label + FROM wptests.use_info_main_read + WHERE id = 1 + ) AS r' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $derived_rows + ); + $derived_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT label FROM use_info_main_read WHERE id = 1', $derived_sql ); + $this->assertStringNotContainsString( 'wptests.use_info_main_read', $derived_sql ); + + $scalar_rows = $driver->query( + 'SELECT ( + SELECT label + FROM public.use_info_main_read + WHERE id = 2 + ) AS label' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $scalar_rows + ); + $scalar_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT label FROM use_info_main_read WHERE id = 2', $scalar_sql ); + $this->assertStringNotContainsString( 'public.use_info_main_read', $scalar_sql ); + + $predicate_rows = $driver->query( + "SELECT label + FROM wptests.use_info_main_read + WHERE id IN ( + SELECT id + FROM wptests.use_info_main_read_two + WHERE label = 'second' + )" + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $predicate_rows + ); + $predicate_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT id FROM use_info_main_read_two WHERE label = \'second\'', $predicate_sql ); + $this->assertStringNotContainsString( 'wptests.use_info_main_read_two', $predicate_sql ); + } + + /** + * Tests nested unqualified application SELECT reads fail closed under USE information_schema. + */ + public function test_use_statement_information_schema_rejects_unqualified_nested_application_selects(): void { + $queries = array( + 'SELECT label FROM (SELECT label FROM use_info_main_read) AS r', + 'SELECT (SELECT label FROM use_info_main_read) AS label', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $this->assertSame( 0, $driver->query( 'USE information_schema' ), $query ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unqualified nested application SELECT under USE information_schema to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests writes after USE information_schema fail before backend execution. + */ + public function test_use_statement_information_schema_writes_fail_closed(): void { + $queries = array( + 'INSERT INTO tables (table_name) VALUES (\'t\')', + 'REPLACE INTO tables (table_name) VALUES (\'t\')', + 'UPDATE tables SET table_name = \'new_t\' WHERE table_name = \'t\'', + 'DELETE FROM tables WHERE table_name = \'t\'', + 'TRUNCATE tables', + 'CREATE TABLE new_table (id INT)', + 'ALTER TABLE tables ADD COLUMN new_column INT', + 'DROP TABLE tables', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE tables (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ), $query ); + + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema write to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests table administration statements return MySQL-shaped success rows. + */ + public function test_table_administration_statements_return_mysql_shaped_success_rows(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_two (id INTEGER)' ); + + $cases = array( + 'ANALYZE TABLE administration_one' => 'analyze', + 'ANALYZE TABLES administration_one' => 'analyze', + 'CHECK TABLE `administration_one`' => 'check', + 'CHECK TABLES `administration_one`' => 'check', + 'OPTIMIZE TABLE administration_one' => 'optimize', + 'OPTIMIZE TABLES administration_one' => 'optimize', + 'REPAIR TABLE administration_one' => 'repair', + 'REPAIR TABLES administration_one' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_one', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + array_column( $driver->get_last_column_meta(), 'name' ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $qualified_rows = $driver->query( 'CHECK TABLE `wptests`.`administration_two`' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_two', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $qualified_rows + ); + } + + /** + * Tests table administration statements preserve multiple-table order. + */ + public function test_table_administration_multiple_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_first (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_second (id INTEGER)' ); + + $rows = $driver->query( 'CHECK TABLE administration_second, administration_first' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_second', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_first', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements return MySQL-shaped missing-table errors. + */ + public function test_table_administration_missing_table_returns_error_and_failed_status(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'OPTIMIZE TABLE administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements preserve mixed existing and missing table order. + */ + public function test_table_administration_mixed_existing_and_missing_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $rows = $driver->query( 'REPAIR TABLE administration_existing, administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_existing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests supported MySQL table administration modifiers are accepted as compatibility no-ops. + */ + public function test_table_administration_accepts_supported_mysql_option_clauses(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $cases = array( + 'ANALYZE LOCAL TABLE administration_existing' => 'analyze', + 'ANALYZE NO_WRITE_TO_BINLOG TABLE administration_existing' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id USING DATA \'{\"buckets\": []}\'' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS USING DATA \'{\"buckets\": []}\'' => 'analyze', + 'ANALYZE TABLE administration_existing DROP HISTOGRAM ON `id`' => 'analyze', + 'CHECK TABLE administration_existing FOR UPGRADE' => 'check', + 'CHECK TABLE administration_existing QUICK FAST MEDIUM EXTENDED CHANGED' => 'check', + 'OPTIMIZE LOCAL TABLE administration_existing' => 'optimize', + 'OPTIMIZE NO_WRITE_TO_BINLOG TABLE administration_existing' => 'optimize', + 'REPAIR LOCAL TABLE administration_existing QUICK EXTENDED USE_FRM' => 'repair', + 'REPAIR NO_WRITE_TO_BINLOG TABLE administration_existing USE_FRM' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_existing', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported table administration clauses fail before reaching the backend. + */ + public function test_table_administration_unsupported_clauses_fail_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $queries = array( + 'CHECK TABLE administration_existing UNKNOWN_OPTION', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH ten BUCKETS', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id USING DATA', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS USING \'{}\'', + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported table administration clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL SHOW OPEN TABLES uses pg_catalog relation and lock rows. + */ + public function test_real_pgsql_show_open_tables_uses_postgresql_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL SHOW OPEN TABLES test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task897' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $options_table = 'task897_' . $suffix . '_options'; + $posts_table = 'task897_' . $suffix . '_posts'; + $database_literal = $driver->get_connection()->quote( $database_name ); + $options_literal = $driver->get_connection()->quote( $options_table ); + $like_literal = $driver->get_connection()->quote( 'task897_' . $suffix . '%posts' ); + $database_ident = '`' . str_replace( '`', '``', $database_name ) . '`'; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $options_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE SHOW OPEN TABLES options table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(191) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $posts_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE SHOW OPEN TABLES posts table', + $backend_sql + ); + + $rows = $driver->query( + "SHOW OPEN TABLES WHERE `Database` = {$database_literal} AND `Table` = {$options_literal}" + ); + $exact_open_table_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW OPEN TABLES exact table', + $backend_sql + ); + $this->assertCount( 1, $rows ); + $this->assertSame( array( 'Database', 'Table', 'In_use', 'Name_locked' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( $database_name, $this->get_row_value( $rows[0], 'Database' ) ); + $this->assertSame( $options_table, $this->get_row_value( $rows[0], 'Table' ) ); + $this->assertContains( $this->get_row_value( $rows[0], 'In_use' ), array( '0', '1' ) ); + $this->assertContains( $this->get_row_value( $rows[0], 'Name_locked' ), array( '0', '1' ) ); + $this->assert_show_open_tables_catalog_query_shape( $exact_open_table_queries[0] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $like_rows = $driver->query( "SHOW OPEN TABLES FROM {$database_ident} LIKE {$like_literal}" ); + $like_open_table_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW OPEN TABLES LIKE posts table', + $backend_sql + ); + $this->assertCount( 1, $like_rows ); + $this->assertSame( $database_name, $this->get_row_value( $like_rows[0], 'Database' ) ); + $this->assertSame( $posts_table, $this->get_row_value( $like_rows[0], 'Table' ) ); + $this->assert_show_open_tables_catalog_query_shape( $like_open_table_queries[0] ); + + $assoc_rows = $driver->query( "SHOW OPEN TABLES WHERE BINARY `Table` = {$options_literal}", PDO::FETCH_ASSOC ); + $assoc_open_table_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW OPEN TABLES assoc fetch', + $backend_sql + ); + $this->assertCount( 1, $assoc_rows ); + $this->assertSame( array( 'Database', 'Table', 'In_use', 'Name_locked' ), array_keys( $assoc_rows[0] ) ); + $this->assertSame( $options_table, $assoc_rows[0]['Table'] ); + $this->assert_show_open_tables_catalog_query_shape( $assoc_open_table_queries[0] ); + + try { + $driver->query( 'SHOW OPEN TABLES FROM pg_catalog' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW OPEN TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW OPEN TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task897' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task897' ) ); + } + } + + /** + * Tests unsupported SHOW OPEN TABLES clauses fail before backend execution. + */ + public function test_unsupported_show_open_tables_clauses_fail_closed(): void { + $cases = array( + 'SHOW OPEN TABLES LIMIT 1' => 'Unsupported SHOW OPEN TABLES statement.', + 'SHOW OPEN TABLES LIKE `Table`' => 'Unsupported SHOW OPEN TABLES statement.', + "SHOW OPEN TABLES WHERE Bogus = 'x'" => 'Unsupported SHOW OPEN TABLES statement.', + ); + + foreach ( $cases as $query => $message ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW OPEN TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unimplemented MySQL SHOW and administration statements fail before backend execution. + */ + public function test_unimplemented_mysql_show_and_administration_statements_fail_closed(): void { + $cases = array( + 'SHOW ENGINE InnoDB STATUS' => 'Unsupported SHOW statement.', + 'CHECKSUM TABLE administration_existing' => 'Unsupported CHECKSUM TABLE statement.', + 'FLUSH TABLES WITH READ LOCK' => 'Unsupported FLUSH statement.', + 'KILL 1' => 'Unsupported KILL statement.', + 'CACHE INDEX administration_existing IN `default`' => 'Unsupported CACHE INDEX statement.', + 'LOAD INDEX INTO CACHE administration_existing' => 'Unsupported LOAD statement.', + 'BINLOG "unsupported-binlog-event"' => 'Unsupported BINLOG statement.', + 'SHUTDOWN' => 'Unsupported SHUTDOWN statement.', + 'GRANT SELECT ON *.* TO plugin_user' => 'Unsupported GRANT statement.', + 'REVOKE SELECT ON *.* FROM plugin_user' => 'Unsupported REVOKE statement.', + 'ALTER USER plugin_user IDENTIFIED BY "secret"' => 'Unsupported ALTER USER statement.', + 'RESET PERSIST' => 'Unsupported RESET statement.', + 'PURGE BINARY LOGS BEFORE "2024-01-01"' => 'Unsupported PURGE statement.', + 'INSTALL PLUGIN plugin_name SONAME "plugin.so"' => 'Unsupported INSTALL statement.', + 'UNINSTALL PLUGIN plugin_name' => 'Unsupported UNINSTALL statement.', + 'ANALYZE FORMAT = TREE SELECT 1' => 'Unsupported table administration statement.', + ); + + foreach ( $cases as $query => $message ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL administration statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL SHOW TRIGGERS returns empty MySQL-shaped rows. + */ + public function test_real_pgsql_show_triggers_returns_empty_mysql_shaped_rows(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL empty SHOW TRIGGERS test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task901_empty' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task901_empty' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $like_literal = $driver->get_connection()->quote( 'task901_empty_' . $suffix . '_%' ); + $columns = array( + 'Trigger', + 'Event', + 'Table', + 'Statement', + 'Timing', + 'Created', + 'sql_mode', + 'Definer', + 'character_set_client', + 'collation_connection', + 'Database Collation', + ); + $backend_sql = array(); + + try { + $rows = $driver->query( 'SHOW TRIGGERS LIKE ' . $like_literal ); + $queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'empty SHOW TRIGGERS LIKE', + $backend_sql + ); + + $this->assertSame( array(), $rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assert_show_triggers_catalog_query_shape( $queries[0], $database_name ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task901_empty' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task901_empty' ); + $this->assertSame( array(), $this->get_public_pgsql_functions_with_prefix( $pdo, 'task901_empty' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task901_empty' ) ); + } + } + + /** + * Tests PostgreSQL-backed SHOW TRIGGERS uses information_schema trigger rows. + */ + public function test_real_pgsql_show_triggers_uses_postgresql_information_schema(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL SHOW TRIGGERS test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task901' ); + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task901' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $options_table = 'task901_' . $suffix . '_options'; + $posts_table = 'task901_' . $suffix . '_posts'; + $options_function = 'task901_' . $suffix . '_options_trigger_fn'; + $posts_function = 'task901_' . $suffix . '_posts_trigger_fn'; + $options_trigger = 'task901_' . $suffix . '_options_insert'; + $posts_trigger = 'task901_' . $suffix . '_posts_update'; + $options_literal = $driver->get_connection()->quote( $options_table ); + $options_trigger_literal = $driver->get_connection()->quote( $options_trigger ); + $posts_trigger_like = $driver->get_connection()->quote( 'task901_' . $suffix . '_%posts%' ); + $database_ident = '`' . str_replace( '`', '``', $database_name ) . '`'; + $trigger_columns = array( + 'Trigger', + 'Event', + 'Table', + 'Statement', + 'Timing', + 'Created', + 'sql_mode', + 'Definer', + 'character_set_client', + 'collation_connection', + 'Database Collation', + ); + $quote_identifier = static function ( string $identifier ): string { + return WP_PostgreSQL_Connection::quote_identifier_value( $identifier ); + }; + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $options_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE SHOW TRIGGERS options table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(191) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $posts_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE SHOW TRIGGERS posts table', + $backend_sql + ); + + foreach ( array( $options_function, $posts_function ) as $function_name ) { + $pdo->exec( + 'CREATE FUNCTION ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $function_name ) + . '() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NEW; +END; +$$' + ); + } + + $pdo->exec( + 'CREATE TRIGGER ' + . $quote_identifier( $options_trigger ) + . ' BEFORE INSERT ON ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $options_table ) + . ' FOR EACH ROW EXECUTE FUNCTION ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $options_function ) + . '()' + ); + $pdo->exec( + 'CREATE TRIGGER ' + . $quote_identifier( $posts_trigger ) + . ' AFTER UPDATE ON ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $posts_table ) + . ' FOR EACH ROW EXECUTE FUNCTION ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $posts_function ) + . '()' + ); + + $rows = $driver->query( + "SHOW TRIGGERS WHERE Event = 'INSERT' AND `Table` = {$options_literal}" + ); + $exact_trigger_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TRIGGERS exact trigger', + $backend_sql + ); + $this->assertCount( 1, $rows ); + $this->assertSame( $trigger_columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( $options_trigger, $this->get_row_value( $rows[0], 'Trigger' ) ); + $this->assertSame( 'INSERT', $this->get_row_value( $rows[0], 'Event' ) ); + $this->assertSame( $options_table, $this->get_row_value( $rows[0], 'Table' ) ); + $this->assertStringContainsString( $options_function, (string) $this->get_row_value( $rows[0], 'Statement' ) ); + $this->assertSame( 'BEFORE', $this->get_row_value( $rows[0], 'Timing' ) ); + $this->assertIsString( $this->get_row_value( $rows[0], 'sql_mode' ) ); + $this->assertIsString( $this->get_row_value( $rows[0], 'Definer' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $rows[0], 'character_set_client' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $rows[0], 'collation_connection' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $rows[0], 'Database Collation' ) ); + $this->assert_show_triggers_catalog_query_shape( $exact_trigger_queries[0], $database_name ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $like_rows = $driver->query( "SHOW TRIGGERS FROM {$database_ident} LIKE {$posts_trigger_like}" ); + $like_trigger_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TRIGGERS LIKE posts trigger', + $backend_sql + ); + $this->assertCount( 1, $like_rows ); + $this->assertSame( $posts_trigger, $this->get_row_value( $like_rows[0], 'Trigger' ) ); + $this->assertSame( 'UPDATE', $this->get_row_value( $like_rows[0], 'Event' ) ); + $this->assertSame( $posts_table, $this->get_row_value( $like_rows[0], 'Table' ) ); + $this->assertStringContainsString( $posts_function, (string) $this->get_row_value( $like_rows[0], 'Statement' ) ); + $this->assertSame( 'AFTER', $this->get_row_value( $like_rows[0], 'Timing' ) ); + $this->assert_show_triggers_catalog_query_shape( $like_trigger_queries[0], $database_name ); + + $assoc_rows = $driver->query( "SHOW TRIGGERS WHERE BINARY `Trigger` = {$options_trigger_literal}", PDO::FETCH_ASSOC ); + $assoc_trigger_queries = $driver->get_last_postgresql_queries(); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW TRIGGERS assoc trigger', + $backend_sql + ); + $this->assertCount( 1, $assoc_rows ); + $this->assertSame( $trigger_columns, array_keys( $assoc_rows[0] ) ); + $this->assertSame( $options_trigger, $assoc_rows[0]['Trigger'] ); + $this->assertSame( 'INSERT', $assoc_rows[0]['Event'] ); + $this->assertSame( $options_table, $assoc_rows[0]['Table'] ); + $this->assert_show_triggers_catalog_query_shape( $assoc_trigger_queries[0], $database_name ); + + try { + $driver->query( 'SHOW TRIGGERS FROM pg_catalog' ); + $this->fail( 'Expected internal PostgreSQL schema SHOW TRIGGERS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TRIGGERS statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + foreach ( + array( + array( $options_trigger, $options_table ), + array( $posts_trigger, $posts_table ), + ) as $trigger + ) { + $pdo->exec( + 'DROP TRIGGER IF EXISTS ' + . $quote_identifier( $trigger[0] ) + . ' ON ' + . $quote_identifier( 'public' ) + . '.' + . $quote_identifier( $trigger[1] ) + ); + } + $this->drop_public_pgsql_functions_with_prefix( $pdo, 'task901' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task901' ); + $trigger_stmt = $pdo->prepare( + "SELECT trigger_name + FROM information_schema.triggers + WHERE trigger_schema = 'public' + AND trigger_name LIKE ? ESCAPE '\\' + ORDER BY trigger_name" + ); + $trigger_stmt->execute( array( 'task901\\_%' ) ); + $this->assertSame( array(), $trigger_stmt->fetchAll( PDO::FETCH_COLUMN ) ); + $this->assertSame( array(), $this->get_public_pgsql_functions_with_prefix( $pdo, 'task901' ) ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task901' ) ); + } + } + + /** + * Tests information_schema.EVENTS is a stateless empty MySQL-shaped relation. + */ + public function test_direct_information_schema_events_returns_empty_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + 'SELECT EVENT_CATALOG, EVENT_SCHEMA, EVENT_NAME, STATUS + FROM information_schema.EVENTS + WHERE EVENT_SCHEMA = DATABASE() + ORDER BY EVENT_NAME' + ); + + $this->assertSame( array(), $rows ); + $this->assertNotEmpty( + array_filter( + $driver->get_last_postgresql_queries(), + static function ( array $query ): bool { + return false !== strpos( $query['sql'], 'AS "events"' ) + && false !== strpos( $query['sql'], '"EVENT_NAME"' ) + && false !== strpos( $query['sql'], 'WHERE 1 = 0' ); + } + ) + ); + } + + /** + * Tests real PostgreSQL information_schema.PARTITIONS uses native partition catalogs. + */ + public function test_real_pgsql_direct_information_schema_partitions_uses_partition_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema partitions test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $parent_table = 'task968_' . $suffix . '_parent'; + $low_partition = 'task968_' . $suffix . '_p_low'; + $high_partition = 'task968_' . $suffix . '_p_high'; + $plain_table = 'task968_' . $suffix . '_plain'; + $backend_sql = array(); + + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task968' ); + + try { + $pdo->exec( + 'CREATE TABLE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) . ' ( + id integer NOT NULL, + sample_value text NOT NULL + ) PARTITION BY RANGE (id)' + ); + $pdo->exec( + 'CREATE TABLE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $low_partition ) + . ' PARTITION OF public.' . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) + . ' FOR VALUES FROM (0) TO (100)' + ); + $pdo->exec( + 'CREATE TABLE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $high_partition ) + . ' PARTITION OF public.' . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) + . ' FOR VALUES FROM (100) TO (200)' + ); + $pdo->exec( + 'CREATE TABLE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $plain_table ) . ' ( + id integer NOT NULL, + sample_value text NOT NULL + )' + ); + + $parent_insert = $pdo->prepare( + 'INSERT INTO public.' . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) . ' (id, sample_value) VALUES (?, ?)' + ); + foreach ( array( 1, 25, 125, 175 ) as $id ) { + $parent_insert->execute( array( $id, 'partition-' . $id ) ); + } + + $plain_insert = $pdo->prepare( + 'INSERT INTO public.' . WP_PostgreSQL_Connection::quote_identifier_value( $plain_table ) . ' (id, sample_value) VALUES (?, ?)' + ); + foreach ( array( 1, 2, 3 ) as $id ) { + $plain_insert->execute( array( $id, 'plain-' . $id ) ); + } + + $pdo->exec( 'ANALYZE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $parent_table ) ); + $pdo->exec( 'ANALYZE public.' . WP_PostgreSQL_Connection::quote_identifier_value( $plain_table ) ); + + $rows = $driver->query( + 'SELECT TABLE_SCHEMA, TABLE_NAME, PARTITION_NAME, + PARTITION_ORDINAL_POSITION, PARTITION_METHOD, PARTITION_EXPRESSION, + PARTITION_DESCRIPTION, TABLE_ROWS, PARTITION_COMMENT, NODEGROUP, TABLESPACE_NAME + FROM information_schema.partitions + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (' + . $driver->get_connection()->quote( $parent_table ) . ', ' + . $driver->get_connection()->quote( $plain_table ) . ') + ORDER BY TABLE_NAME, PARTITION_ORDINAL_POSITION, PARTITION_NAME' + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.partitions real PostgreSQL', + $backend_sql + ); + + $this->assertSame( + array( + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'PARTITION_NAME', + 'PARTITION_ORDINAL_POSITION', + 'PARTITION_METHOD', + 'PARTITION_EXPRESSION', + 'PARTITION_DESCRIPTION', + 'TABLE_ROWS', + 'PARTITION_COMMENT', + 'NODEGROUP', + 'TABLESPACE_NAME', + ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $parent_rows = array_values( + array_filter( + $rows, + function ( $row ) use ( $parent_table ): bool { + return $parent_table === $this->get_row_value( $row, 'TABLE_NAME' ); + } + ) + ); + $this->assertCount( 2, $parent_rows ); + $this->assertSame( + array( $high_partition, $low_partition ), + array_map( + function ( $row ): string { + return (string) $this->get_row_value( $row, 'PARTITION_NAME' ); + }, + $parent_rows + ) + ); + + foreach ( $parent_rows as $index => $partition_row ) { + $this->assertSame( $database_name, $this->get_row_value( $partition_row, 'TABLE_SCHEMA' ) ); + $this->assertSame( $parent_table, $this->get_row_value( $partition_row, 'TABLE_NAME' ) ); + $this->assertSame( (string) ( $index + 1 ), (string) $this->get_row_value( $partition_row, 'PARTITION_ORDINAL_POSITION' ) ); + $this->assertSame( 'RANGE', $this->get_row_value( $partition_row, 'PARTITION_METHOD' ) ); + $this->assertStringContainsString( 'id', strtolower( (string) $this->get_row_value( $partition_row, 'PARTITION_EXPRESSION' ) ) ); + $this->assertNotSame( '', (string) $this->get_row_value( $partition_row, 'PARTITION_DESCRIPTION' ) ); + $this->assertRegExp( '/^[0-9]+$/', (string) $this->get_row_value( $partition_row, 'TABLE_ROWS' ) ); + $this->assertSame( '', $this->get_row_value( $partition_row, 'PARTITION_COMMENT' ) ); + $this->assertSame( 'default', $this->get_row_value( $partition_row, 'NODEGROUP' ) ); + $this->assertSame( 'DEFAULT', $this->get_row_value( $partition_row, 'TABLESPACE_NAME' ) ); + } + + $plain_row = $this->find_row_by_value( $rows, 'TABLE_NAME', $plain_table ); + $this->assertNotNull( $plain_row ); + $this->assertSame( $database_name, $this->get_row_value( $plain_row, 'TABLE_SCHEMA' ) ); + $this->assertNull( $this->get_row_value( $plain_row, 'PARTITION_NAME' ) ); + $this->assertNull( $this->get_row_value( $plain_row, 'PARTITION_ORDINAL_POSITION' ) ); + $this->assertNull( $this->get_row_value( $plain_row, 'PARTITION_METHOD' ) ); + $this->assertSame( 'DEFAULT', $this->get_row_value( $plain_row, 'TABLESPACE_NAME' ) ); + + $this->assertStringContainsString( 'FROM pg_catalog.pg_inherits inh', $sql ); + $this->assertStringContainsString( 'JOIN pg_catalog.pg_class child_class', $sql ); + $this->assertStringContainsString( 'JOIN pg_catalog.pg_class parent_class', $sql ); + $this->assertStringContainsString( 'pg_catalog.pg_get_partkeydef(parent_class.oid)', $sql ); + $this->assertStringContainsString( 'pg_catalog.pg_get_expr(child_class.relpartbound, child_class.oid) AS "PARTITION_DESCRIPTION"', $sql ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $sql ); + $this->assertStringContainsString( 't.table_type = \'BASE TABLE\'', $sql ); + $this->assertStringContainsString( 'UNION ALL', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task968' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task968' ) ); + } + } + + /** + * Tests SHOW EVENTS returns empty MySQL-shaped rows without side metadata. + */ + public function test_show_events_returns_empty_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + $columns = array( + 'Db', + 'Name', + 'Definer', + 'Time zone', + 'Type', + 'Execute at', + 'Interval value', + 'Interval field', + 'Starts', + 'Ends', + 'Status', + 'Originator', + 'character_set_client', + 'collation_connection', + 'Database Collation', + ); + + $rows = $driver->query( 'SHOW EVENTS' ); + + $this->assertSame( array(), $rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $like_rows = $driver->query( "SHOW EVENTS LIKE 'ev_%'" ); + $this->assertSame( array(), $like_rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + + $where_rows = $driver->query( "SHOW EVENTS WHERE Status = 'ENABLED' AND Name = 'ev_options'" ); + $this->assertSame( array(), $where_rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + + $assoc_rows = $driver->query( "SHOW EVENTS FROM wptests WHERE BINARY Name = 'ev_options'", PDO::FETCH_ASSOC ); + $this->assertSame( array(), $assoc_rows ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ) ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests unsupported SHOW EVENTS clauses fail before backend execution. + */ + public function test_unsupported_show_events_clauses_fail_closed(): void { + $cases = array( + 'SHOW EVENTS LIMIT 1' => 'Unsupported SHOW EVENTS statement.', + 'SHOW EVENTS LIKE Name' => 'Unsupported SHOW EVENTS statement.', + "SHOW EVENTS WHERE Bogus = 'x'" => 'Unsupported SHOW EVENTS statement.', + 'SHOW EVENTS FROM pg_catalog' => 'Unsupported SHOW EVENTS statement.', + ); + + foreach ( $cases as $query => $message ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW EVENTS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests account/plugin/log administration statements do not reach the backend fallback. + */ + public function test_unsupported_mysql_account_and_plugin_administration_statements_do_not_reach_backend(): void { + $cases = array( + 'GRANT SELECT ON *.* TO plugin_user' => 'Unsupported GRANT statement.', + 'REVOKE SELECT ON *.* FROM plugin_user' => 'Unsupported REVOKE statement.', + 'ALTER USER plugin_user IDENTIFIED BY "secret"' => 'Unsupported ALTER USER statement.', + 'RESET PERSIST' => 'Unsupported RESET statement.', + 'PURGE BINARY LOGS BEFORE "2024-01-01"' => 'Unsupported PURGE statement.', + 'INSTALL PLUGIN plugin_name SONAME "plugin.so"' => 'Unsupported INSTALL statement.', + 'UNINSTALL PLUGIN plugin_name' => 'Unsupported UNINSTALL statement.', + ); + + foreach ( $cases as $query => $message ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL administration statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests information_schema table administration targets fail closed. + */ + public function test_table_administration_information_schema_target_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CHECK TABLE `information_schema`.`tables`' ); + $this->fail( 'Expected information_schema table administration target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests PostgreSQL-backed information_schema.TABLES does not use PHP materialized rows. + */ + public function test_direct_information_schema_pgsql_tables_rows_helper_is_removed(): void { + $reflection = new ReflectionClass( WP_PostgreSQL_Driver::class ); + + $this->assertFalse( $reflection->hasMethod( 'get_direct_information_schema_table_rows' ) ); + } + + /** + * Tests real PostgreSQL Site Health grouped information_schema.TABLES uses catalogs. + */ + public function test_real_pgsql_site_health_information_schema_tables_grouped_rows_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL Site Health information_schema.TABLES test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task915' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $database_literal = $driver->get_connection()->quote( $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $tables = array( + 'task915_' . $suffix . '_options', + 'task915_' . $suffix . '_posts', + 'task915_' . $suffix . '_users', + ); + $table_literals = array_map( array( $driver->get_connection(), 'quote' ), $tables ); + $backend_sql = array(); + + try { + foreach ( $tables as $table ) { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE Site Health information_schema.TABLES fixture table', + $backend_sql + ); + + $quoted_table = WP_PostgreSQL_Connection::quote_identifier_value( $table ); + $pdo->exec( 'INSERT INTO public.' . $quoted_table . " (title) VALUES ('one'), ('two')" ); + $pdo->exec( 'ANALYZE public.' . $quoted_table ); + } + + $rows = $driver->query( + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = {$database_literal} + AND TABLE_NAME IN (" . implode( ',', $table_literals ) . ') + GROUP BY TABLE_NAME + ORDER BY TABLE_NAME' + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL Site Health grouped information_schema.TABLES query', + $backend_sql + ); + + $this->assertCount( 3, $rows ); + $this->assertSame( $tables, array_map( 'strval', array_column( $rows, 'table' ) ) ); + foreach ( $rows as $row ) { + $this->assertTrue( is_numeric( $this->get_row_value( $row, 'rows' ) ) ); + $this->assertGreaterThanOrEqual( 0, (int) $this->get_row_value( $row, 'rows' ) ); + $this->assertTrue( is_numeric( $this->get_row_value( $row, 'bytes' ) ) ); + $this->assertGreaterThanOrEqual( 0, (int) $this->get_row_value( $row, 'bytes' ) ); + } + + $this->assertStringContainsString( 'FROM information_schema.tables t', $sql ); + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( 'AS "rows"', $sql ); + $this->assertStringContainsString( 'as "bytes"', $sql ); + $this->assertStringContainsString( 'GROUP BY "TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'SUM ( "DATA_LENGTH" + "INDEX_LENGTH" ) as "bytes"', $sql ); + $this->assertStringContainsString( 'pg_catalog.pg_stat_all_tables pg_stat', $sql ); + $this->assertStringContainsString( 'pg_stat.n_live_tup', $sql ); + $this->assertRegExp( '/MAX\(\s*"tables"\."TABLE_ROWS"\s*\) AS "rows"/', $sql ); + $this->assertStringNotContainsString( '"TABLE_ROWS" AS "rows"', $sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task915' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task915' ) ); + } + } + + /** + * Tests real PostgreSQL runtime helper domains and enum/set helpers use catalogs. + */ + public function test_real_pgsql_runtime_helper_domains_and_enum_set_helpers_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL runtime helper catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1009' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql, array $params = array() ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $domains_table = 'task1009_' . $suffix . '_domains'; + $helpers_table = 'task1009_' . $suffix . '_helpers'; + $alter_table = 'task1009_' . $suffix . '_alter_helpers'; + $backend_sql = array(); + + $assert_logged_sql_before = function ( array $sql_log, string $before, string $after ): void { + $before_index = null; + $after_index = null; + + foreach ( $sql_log as $index => $sql ) { + if ( null === $before_index && false !== strpos( $sql, $before ) ) { + $before_index = $index; + } + if ( null === $after_index && false !== strpos( $sql, $after ) ) { + $after_index = $index; + } + } + + $this->assertNotNull( $before_index, 'Expected logged SQL containing: ' . $before ); + $this->assertNotNull( $after_index, 'Expected logged SQL containing: ' . $after ); + $this->assertLessThan( $after_index, $before_index, $before . ' should run before ' . $after ); + }; + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int(11) NOT NULL, + `flags` bit(10), + `enabled` bool, + `toggled` boolean, + `object_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `tiny_flag` tinyint(1) unsigned DEFAULT NULL, + `alias_count` int4 unsigned DEFAULT NULL, + `alias_big` int8 DEFAULT NULL, + `created_date` date, + `created_at` datetime(6), + `updated_at` timestamp, + `touched_at` time(3), + `year_seen` year, + `hash` binary(32), + `payload` varbinary(16), + `plain_blob` blob, + `raw_data` mediumblob, + `shape` point, + `amount` dec(10,2), + `fixed_value` fixed(8,3), + `ratio` float(5,2), + `score` double(5,2), + `measurement` numeric(8,4), + `real_value` real, + PRIMARY KEY (`id`), + KEY `object_lookup` (`object_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $domains_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE task1009 helper domain table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` int(11) NOT NULL, + `status` enum('draft','published') NOT NULL DEFAULT 'draft', + `flags` set('featured','archived') DEFAULT 'featured', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + $helpers_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE task1009 enum/set helper table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` int(11) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $alter_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE task1009 ALTER helper table', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "ALTER TABLE `%s` + ADD `status` enum('draft','published') NOT NULL DEFAULT 'draft', + ADD `flags` set('featured','archived') DEFAULT 'featured'", + $alter_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'ALTER task1009 ADD enum/set helper columns', + $backend_sql + ); + + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_integer_domain$', 'CREATE TABLE "' . $domains_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_text_domain$', 'CREATE TABLE "' . $domains_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_binary_domain$', 'CREATE TABLE "' . $domains_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_numeric_domain$', 'CREATE TABLE "' . $domains_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_enum_type$', 'CREATE TABLE "' . $helpers_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'DO $wp_mysql_set_domain$', 'CREATE TABLE "' . $helpers_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'COMMENT ON DOMAIN "__wp_mysql_set_3c255ba5f59d3194"', 'CREATE TABLE "' . $helpers_table . '"' ); + $assert_logged_sql_before( $logged_sql, 'CREATE TYPE "__wp_mysql_enum_02ccf983d79439de"', 'ALTER TABLE "' . $alter_table . '" ADD COLUMN "status"' ); + $assert_logged_sql_before( $logged_sql, 'COMMENT ON DOMAIN "__wp_mysql_set_3c255ba5f59d3194"', 'ALTER TABLE "' . $alter_table . '" ADD COLUMN "flags"' ); + + $type_rows = $this->get_pgsql_type_rows_by_name( + $pdo, + 'public', + array( + '__wp_mysql_int_11', + '__wp_mysql_bit_10', + '__wp_mysql_bool', + '__wp_mysql_boolean', + '__wp_mysql_bigint_20_unsigned', + '__wp_mysql_tinyint_1_unsigned', + '__wp_mysql_int4_unsigned', + '__wp_mysql_int8', + '__wp_mysql_date', + '__wp_mysql_datetime_6', + '__wp_mysql_timestamp', + '__wp_mysql_time_3', + '__wp_mysql_year', + '__wp_mysql_binary_32', + '__wp_mysql_varbinary_16', + '__wp_mysql_blob', + '__wp_mysql_mediumblob', + '__wp_mysql_point', + '__wp_mysql_dec_10_2', + '__wp_mysql_fixed_8_3', + '__wp_mysql_float_5_2', + '__wp_mysql_double_5_2', + '__wp_mysql_numeric_8_4', + '__wp_mysql_real', + '__wp_mysql_enum_02ccf983d79439de', + '__wp_mysql_set_3c255ba5f59d3194', + ) + ); + + foreach ( array( '__wp_mysql_int_11', '__wp_mysql_bit_10', '__wp_mysql_bool', '__wp_mysql_boolean', '__wp_mysql_tinyint_1_unsigned', '__wp_mysql_int4_unsigned' ) as $domain_name ) { + $this->assertSame( 'd', $type_rows[ $domain_name ]['typtype'] ?? null, $domain_name ); + $this->assertSame( 'integer', $type_rows[ $domain_name ]['base_type'] ?? null, $domain_name ); + } + foreach ( array( '__wp_mysql_bigint_20_unsigned', '__wp_mysql_int8' ) as $domain_name ) { + $this->assertSame( 'd', $type_rows[ $domain_name ]['typtype'] ?? null, $domain_name ); + $this->assertSame( 'bigint', $type_rows[ $domain_name ]['base_type'] ?? null, $domain_name ); + } + foreach ( array( '__wp_mysql_date', '__wp_mysql_datetime_6', '__wp_mysql_timestamp', '__wp_mysql_time_3', '__wp_mysql_year', '__wp_mysql_point' ) as $domain_name ) { + $this->assertSame( 'd', $type_rows[ $domain_name ]['typtype'] ?? null, $domain_name ); + $this->assertSame( 'text', $type_rows[ $domain_name ]['base_type'] ?? null, $domain_name ); + } + foreach ( array( '__wp_mysql_binary_32', '__wp_mysql_varbinary_16', '__wp_mysql_blob', '__wp_mysql_mediumblob' ) as $domain_name ) { + $this->assertSame( 'd', $type_rows[ $domain_name ]['typtype'] ?? null, $domain_name ); + $this->assertSame( 'bytea', $type_rows[ $domain_name ]['base_type'] ?? null, $domain_name ); + } + foreach ( + array( + '__wp_mysql_dec_10_2' => 'numeric(10,2)', + '__wp_mysql_fixed_8_3' => 'numeric(8,3)', + '__wp_mysql_float_5_2' => 'numeric(5,2)', + '__wp_mysql_double_5_2' => 'numeric(5,2)', + '__wp_mysql_numeric_8_4' => 'numeric(8,4)', + '__wp_mysql_real' => 'double precision', + ) as $domain_name => $base_type + ) { + $this->assertSame( 'd', $type_rows[ $domain_name ]['typtype'] ?? null, $domain_name ); + $this->assertSame( $base_type, $type_rows[ $domain_name ]['base_type'] ?? null, $domain_name ); + } + $this->assertSame( 'e', $type_rows['__wp_mysql_enum_02ccf983d79439de']['typtype'] ?? null ); + $this->assertSame( + '__wp_mysql_column_type:c2V0KCdmZWF0dXJlZCcsJ2FyY2hpdmVkJyk=', + $type_rows['__wp_mysql_set_3c255ba5f59d3194']['type_comment'] ?? null + ); + $this->assertSame( + array( 'draft', 'published' ), + $this->get_pgsql_enum_labels( $pdo, 'public', '__wp_mysql_enum_02ccf983d79439de' ) + ); + + $column_stmt = $pdo->prepare( + 'SELECT table_name, column_name, udt_name, domain_name + FROM information_schema.columns + WHERE table_schema = ? + AND table_name IN (?, ?, ?) + ORDER BY table_name, ordinal_position' + ); + $column_stmt->execute( array( 'public', $domains_table, $helpers_table, $alter_table ) ); + $catalog_columns = array(); + foreach ( $column_stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $catalog_columns[ $row['table_name'] ][ $row['column_name'] ] = $row; + } + $this->assertSame( '__wp_mysql_int_11', $catalog_columns[ $domains_table ]['id']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_datetime_6', $catalog_columns[ $domains_table ]['created_at']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_mediumblob', $catalog_columns[ $domains_table ]['raw_data']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_varbinary_16', $catalog_columns[ $domains_table ]['payload']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_dec_10_2', $catalog_columns[ $domains_table ]['amount']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_fixed_8_3', $catalog_columns[ $domains_table ]['fixed_value']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_real', $catalog_columns[ $domains_table ]['real_value']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_enum_02ccf983d79439de', $catalog_columns[ $helpers_table ]['status']['udt_name'] ?? null ); + $this->assertSame( '__wp_mysql_set_3c255ba5f59d3194', $catalog_columns[ $helpers_table ]['flags']['domain_name'] ?? null ); + $this->assertSame( '__wp_mysql_enum_02ccf983d79439de', $catalog_columns[ $alter_table ]['status']['udt_name'] ?? null ); + $this->assertSame( '__wp_mysql_set_3c255ba5f59d3194', $catalog_columns[ $alter_table ]['flags']['domain_name'] ?? null ); + + $domain_columns = $driver->query( 'SHOW COLUMNS FROM `' . $domains_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS task1009 helper domain table', + $backend_sql + ); + foreach ( + array( + 'id' => 'int(11)', + 'flags' => 'bit(10)', + 'enabled' => 'bool', + 'toggled' => 'bool(an)', + 'object_id' => 'bigint(20) unsigned', + 'tiny_flag' => 'tinyint(1) unsigned', + 'alias_count' => 'int() unsigned', + 'alias_big' => 'int()', + 'created_date' => 'date', + 'created_at' => 'datetime(6)', + 'updated_at' => 'timestamp', + 'touched_at' => 'time(3)', + 'year_seen' => 'year', + 'hash' => 'binary(32)', + 'payload' => 'varbinary(16)', + 'plain_blob' => 'blob', + 'raw_data' => 'mediumblob', + 'shape' => 'point', + 'amount' => 'dec(10,2)', + 'fixed_value' => 'fixed(8,3)', + 'ratio' => 'float(5,2)', + 'score' => 'double(5,2)', + 'measurement' => 'numeric(8,4)', + 'real_value' => 'real', + ) as $column_name => $column_type + ) { + $this->assertSame( + $column_type, + $this->get_row_value( $this->find_row_by_value( $domain_columns, 'Field', $column_name ), 'Type' ), + $column_name + ); + } + + $helper_columns = $driver->query( 'SHOW COLUMNS FROM `' . $helpers_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS task1009 enum/set helper table', + $backend_sql + ); + $this->assertSame( + "enum('draft','published')", + $this->get_row_value( $this->find_row_by_value( $helper_columns, 'Field', 'status' ), 'Type' ) + ); + $this->assertSame( + "set('featured','archived')", + $this->get_row_value( $this->find_row_by_value( $helper_columns, 'Field', 'flags' ), 'Type' ) + ); + + $alter_columns = $driver->query( 'SHOW COLUMNS FROM `' . $alter_table . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS task1009 ALTER helper table', + $backend_sql + ); + $this->assertSame( + "enum('draft','published')", + $this->get_row_value( $this->find_row_by_value( $alter_columns, 'Field', 'status' ), 'Type' ) + ); + $this->assertSame( + "set('featured','archived')", + $this->get_row_value( $this->find_row_by_value( $alter_columns, 'Field', 'flags' ), 'Type' ) + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s` (`id`, `status`, `flags`) VALUES (1, 'published', 'featured,archived')", + $helpers_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'INSERT task1009 enum/set helper row', + $backend_sql + ); + $roundtrip_rows = $driver->query( 'SELECT `status`, `flags` FROM `' . $helpers_table . '` WHERE `id` = 1' ); + $this->collect_last_postgresql_queries( + $driver, + 'SELECT task1009 enum/set helper row', + $backend_sql + ); + $this->assertSame( 'published', $roundtrip_rows[0]->status ); + $this->assertSame( 'featured,archived', $roundtrip_rows[0]->flags ); + + foreach ( $logged_sql as $sql ) { + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + } + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1009' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1009' ) ); + } + } + + /** + * Tests real PostgreSQL information_schema columns/statistics and SHOW wrappers use catalogs. + */ + public function test_real_pgsql_direct_information_schema_columns_statistics_and_show_wrappers_use_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL columns/statistics catalog test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task989' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task989_' . $suffix . '_meta'; + $table_literal = $driver->get_connection()->quote( $table_name ); + $table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $expression_sql = WP_PostgreSQL_Connection::quote_identifier_value( $table_name . '__title_lower_expr' ); + $backend_sql = array(); + + try { + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `object_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `slug` varchar(64) NOT NULL, + `title` varchar(191) NOT NULL COMMENT 'Title note', + `status` enum('draft','published') NOT NULL DEFAULT 'draft' COMMENT 'Status note', + `flags` set('featured','archived') DEFAULT NULL COMMENT 'Flags note', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `payload` mediumblob DEFAULT NULL, + `amount` decimal(10,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (`id`), + UNIQUE KEY `slug_unique` (`slug`), + KEY `title_prefix` (`title`(32)) COMMENT 'Title prefix note', + KEY `created_desc` (`created_at` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Task 989 metadata table'", + $table_name + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE task989 columns/statistics table', + $backend_sql + ); + + $pdo->exec( 'CREATE INDEX ' . $expression_sql . ' ON public.' . $table_sql . ' ((lower(title)))' ); + $pdo->exec( 'COMMENT ON INDEX public.' . $expression_sql . " IS 'Title expression note'" ); + + $type_rows = $this->get_pgsql_type_rows_by_name( + $pdo, + 'public', + array( + '__wp_mysql_bigint_20_unsigned', + '__wp_mysql_datetime_6', + '__wp_mysql_mediumblob', + '__wp_mysql_enum_02ccf983d79439de', + '__wp_mysql_set_3c255ba5f59d3194', + ) + ); + foreach ( array( '__wp_mysql_bigint_20_unsigned', '__wp_mysql_datetime_6', '__wp_mysql_mediumblob', '__wp_mysql_set_3c255ba5f59d3194' ) as $domain_type ) { + $this->assertSame( 'd', $type_rows[ $domain_type ]['typtype'] ?? null, $domain_type ); + } + $this->assertSame( 'e', $type_rows['__wp_mysql_enum_02ccf983d79439de']['typtype'] ?? null ); + $this->assertSame( + '__wp_mysql_column_type:c2V0KCdmZWF0dXJlZCcsJ2FyY2hpdmVkJyk=', + $type_rows['__wp_mysql_set_3c255ba5f59d3194']['type_comment'] ?? null + ); + $this->assertSame( + array( 'draft', 'published' ), + $this->get_pgsql_enum_labels( $pdo, 'public', '__wp_mysql_enum_02ccf983d79439de' ) + ); + + $column_rows = $driver->query( + "SELECT column_name, ordinal_position, data_type, column_type, column_key, + column_default, is_nullable, extra, character_set_name, collation_name, + column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY ordinal_position" + ); + $columns_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.columns real PostgreSQL', + $backend_sql + ); + $this->assertCount( 9, $column_rows ); + $id_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $id_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'PRI', $this->get_row_value( $id_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'auto_increment', $this->get_row_value( $id_column, 'EXTRA' ) ); + $this->assertSame( 'NO', $this->get_row_value( $id_column, 'IS_NULLABLE' ) ); + $object_id_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'object_id' ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $object_id_column, 'COLUMN_TYPE' ) ); + $slug_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'slug' ); + $this->assertSame( 'varchar(64)', $this->get_row_value( $slug_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'UNI', $this->get_row_value( $slug_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $slug_column, 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $slug_column, 'COLLATION_NAME' ) ); + $title_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'title' ); + $this->assertSame( 'varchar(191)', $this->get_row_value( $title_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'MUL', $this->get_row_value( $title_column, 'COLUMN_KEY' ) ); + $this->assertSame( 'Title note', $this->get_row_value( $title_column, 'COLUMN_COMMENT' ) ); + $status_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'status' ); + $this->assertSame( 'enum', $this->get_row_value( $status_column, 'DATA_TYPE' ) ); + $this->assertSame( "enum('draft','published')", $this->get_row_value( $status_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'draft', $this->get_row_value( $status_column, 'COLUMN_DEFAULT' ) ); + $this->assertSame( 'Status note', $this->get_row_value( $status_column, 'COLUMN_COMMENT' ) ); + $flags_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'flags' ); + $this->assertSame( 'set', $this->get_row_value( $flags_column, 'DATA_TYPE' ) ); + $this->assertSame( "set('featured','archived')", $this->get_row_value( $flags_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'Flags note', $this->get_row_value( $flags_column, 'COLUMN_COMMENT' ) ); + $created_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'created_at' ); + $this->assertSame( 'datetime(6)', $this->get_row_value( $created_column, 'COLUMN_TYPE' ) ); + $this->assertSame( 'CURRENT_TIMESTAMP(6)', $this->get_row_value( $created_column, 'COLUMN_DEFAULT' ) ); + $payload_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'payload' ); + $this->assertSame( 'mediumblob', $this->get_row_value( $payload_column, 'DATA_TYPE' ) ); + $this->assertSame( 'mediumblob', $this->get_row_value( $payload_column, 'COLUMN_TYPE' ) ); + $amount_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'amount' ); + $this->assertSame( 'decimal(10,2)', $this->get_row_value( $amount_column, 'COLUMN_TYPE' ) ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $columns_sql ); + $this->assertStringContainsString( 'pg_catalog.col_description(pc.oid, pa.attnum)', $columns_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_index i', $columns_sql ); + $this->assertStringContainsString( 'pg_catalog.pg_enum e', $columns_sql ); + $this->assertStringContainsString( '__wp_mysql_column_type:', $columns_sql ); + + $describe_rows = $driver->query( 'DESCRIBE `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'DESCRIBE task989 table', + $backend_sql + ); + $this->assertSame( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $this->find_row_by_value( $describe_rows, 'Field', 'id' ), 'Type' ) ); + $this->assertSame( "enum('draft','published')", $this->get_row_value( $this->find_row_by_value( $describe_rows, 'Field', 'status' ), 'Type' ) ); + + $show_columns = $driver->query( 'SHOW COLUMNS FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW COLUMNS task989 table', + $backend_sql + ); + $this->assertEquals( $describe_rows, $show_columns ); + + $show_full_columns = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW FULL COLUMNS task989 table', + $backend_sql + ); + $this->assertSame( array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $full_title_column = $this->find_row_by_value( $show_full_columns, 'Field', 'title' ); + $this->assertSame( 'varchar(191)', $this->get_row_value( $full_title_column, 'Type' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $full_title_column, 'Collation' ) ); + $this->assertSame( 'select,insert,update,references', $this->get_row_value( $full_title_column, 'Privileges' ) ); + $this->assertSame( 'Title note', $this->get_row_value( $full_title_column, 'Comment' ) ); + $this->assertSame( "set('featured','archived')", $this->get_row_value( $this->find_row_by_value( $show_full_columns, 'Field', 'flags' ), 'Type' ) ); + + $join_rows = $driver->query( + "SELECT t.table_name, c.column_name, c.column_type, c.column_key + FROM information_schema.tables AS t + JOIN information_schema.columns AS c + ON c.table_schema = t.table_schema + AND c.table_name = t.table_name + WHERE t.table_schema = DATABASE() + AND t.table_name = {$table_literal} + AND c.column_name IN ('id', 'title', 'status') + ORDER BY c.ordinal_position" + ); + $join_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema tables/columns join real PostgreSQL', + $backend_sql + ); + $this->assertSame( array( 'id', 'title', 'status' ), array_map( 'strval', array_column( $join_rows, 'COLUMN_NAME' ) ) ); + foreach ( $join_rows as $join_row ) { + $this->assertSame( $table_name, $this->get_row_value( $join_row, 'TABLE_NAME' ) ); + } + $this->assertSame( 'bigint(20) unsigned', $this->get_row_value( $this->find_row_by_value( $join_rows, 'COLUMN_NAME', 'id' ), 'COLUMN_TYPE' ) ); + $this->assertSame( "enum('draft','published')", $this->get_row_value( $this->find_row_by_value( $join_rows, 'COLUMN_NAME', 'status' ), 'COLUMN_TYPE' ) ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $join_sql ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $join_sql ); + + $statistics_rows = $driver->query( + "SELECT index_name, seq_in_index, column_name, collation, sub_part, + non_unique, index_type, index_comment, expression + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name, seq_in_index" + ); + $statistics_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.statistics real PostgreSQL', + $backend_sql + ); + $primary_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'PRIMARY' ); + $this->assertSame( 'id', $this->get_row_value( $primary_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $primary_statistic, 'SEQ_IN_INDEX' ) ); + $this->assertSame( '0', (string) $this->get_row_value( $primary_statistic, 'NON_UNIQUE' ) ); + $this->assertSame( 'A', $this->get_row_value( $primary_statistic, 'COLLATION' ) ); + $this->assertNull( $this->get_row_value( $primary_statistic, 'SUB_PART' ) ); + $this->assertSame( 'BTREE', $this->get_row_value( $primary_statistic, 'INDEX_TYPE' ) ); + $this->assertSame( '', $this->get_row_value( $primary_statistic, 'INDEX_COMMENT' ) ); + $this->assertNull( $this->get_row_value( $primary_statistic, 'EXPRESSION' ) ); + $slug_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'slug_unique' ); + $this->assertSame( 'slug', $this->get_row_value( $slug_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '0', (string) $this->get_row_value( $slug_statistic, 'NON_UNIQUE' ) ); + $title_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'title_prefix' ); + $this->assertSame( 'title', $this->get_row_value( $title_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $title_statistic, 'NON_UNIQUE' ) ); + $this->assertSame( '32', (string) $this->get_row_value( $title_statistic, 'SUB_PART' ) ); + $this->assertSame( 'Title prefix note', $this->get_row_value( $title_statistic, 'INDEX_COMMENT' ) ); + $created_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'created_desc' ); + $this->assertSame( 'created_at', $this->get_row_value( $created_statistic, 'COLUMN_NAME' ) ); + $this->assertSame( 'D', $this->get_row_value( $created_statistic, 'COLLATION' ) ); + $expression_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'title_lower_expr' ); + $this->assertNull( $this->get_row_value( $expression_statistic, 'COLUMN_NAME' ) ); + $this->assertStringContainsString( 'lower', strtolower( (string) $this->get_row_value( $expression_statistic, 'EXPRESSION' ) ) ); + $this->assertStringContainsString( 'title', strtolower( (string) $this->get_row_value( $expression_statistic, 'EXPRESSION' ) ) ); + $this->assertSame( 'Title expression note', $this->get_row_value( $expression_statistic, 'INDEX_COMMENT' ) ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $statistics_sql ); + $this->assertStringContainsString( 'pg_catalog.unnest(i.indkey)', $statistics_sql ); + $this->assertStringContainsString( 'pg_catalog.obj_description(idx.oid, \'pg_class\')', $statistics_sql ); + $this->assertStringContainsString( 'AS "INDEX_COMMENT"', $statistics_sql ); + $this->assertStringContainsString( 'AS "EXPRESSION"', $statistics_sql ); + + $show_index_rows = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX task989 table', + $backend_sql + ); + $this->assertSame( 15, $driver->get_last_column_count() ); + $this->assertSame( + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Collation', 'Cardinality', 'Sub_part', 'Packed', 'Null', 'Index_type', 'Comment', 'Index_comment', 'Visible', 'Expression' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( 'id', $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'PRIMARY' ), 'Column_name' ) ); + $this->assertSame( '32', $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'title_prefix' ), 'Sub_part' ) ); + $this->assertSame( 'D', $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'created_desc' ), 'Collation' ) ); + + $show_key_rows = $driver->query( 'SHOW KEYS FROM `' . $table_name . '`' ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW KEYS task989 table', + $backend_sql + ); + $this->assertEquals( $show_index_rows, $show_key_rows ); + + $title_prefix_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Key_name = 'title_prefix'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX task989 Key_name filter', + $backend_sql + ); + $this->assertCount( 1, $title_prefix_rows ); + $this->assertSame( '32', $this->get_row_value( $title_prefix_rows[0], 'Sub_part' ) ); + + $descending_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Collation = 'D'" ); + $this->collect_last_postgresql_queries( + $driver, + 'SHOW INDEX task989 Collation filter', + $backend_sql + ); + $this->assertCount( 1, $descending_rows ); + $this->assertSame( 'created_desc', $this->get_row_value( $descending_rows[0], 'Key_name' ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task989' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task989' ) ); + } + } + + /** + * Tests real PostgreSQL SHOW metadata projections keep existing MySQL-shaped output. + */ + public function test_real_pgsql_show_metadata_projection_arrays_preserve_existing_output(): void { + $driver = $this->create_driver(); + $table_name = 'projection_map_metadata'; + $table_literal = $driver->get_connection()->quote( $table_name ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(80) NOT NULL DEFAULT 'untitled' COMMENT 'Title note', + `status` varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + KEY `title_prefix` (`title`(12)) COMMENT 'Title prefix note' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Projection map table note'", + $table_name + ) + ) + ); + + $show_full_columns = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table_name . '`' ); + $full_columns_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertSame( array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $title_column = $this->find_row_by_value( $show_full_columns, 'Field', 'title' ); + $this->assertSame( 'varchar(80)', $this->get_row_value( $title_column, 'Type' ) ); + $this->assertSame( 'NO', $this->get_row_value( $title_column, 'Null' ) ); + $this->assertSame( 'untitled', $this->get_row_value( $title_column, 'Default' ) ); + $this->assertSame( 'Title note', $this->get_row_value( $title_column, 'Comment' ) ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $full_columns_sql ); + + $table_status_rows = $driver->query( 'SHOW TABLE STATUS LIKE ' . $table_literal ); + $table_status_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertSame( $this->get_show_table_status_column_names(), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertCount( 1, $table_status_rows ); + $this->assertSame( $table_name, $this->get_row_value( $table_status_rows[0], 'Name' ) ); + $this->assertSame( 'InnoDB', $this->get_row_value( $table_status_rows[0], 'Engine' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $table_status_rows[0], 'Collation' ) ); + $this->assertSame( 'Projection map table note', $this->get_row_value( $table_status_rows[0], 'Comment' ) ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $table_status_sql ); + + $show_index_rows = $driver->query( "SHOW INDEX FROM `{$table_name}` WHERE Key_name = 'title_prefix'" ); + $show_index_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertCount( 1, $show_index_rows ); + $this->assertSame( 'title_prefix', $this->get_row_value( $show_index_rows[0], 'Key_name' ) ); + $this->assertSame( 'title', $this->get_row_value( $show_index_rows[0], 'Column_name' ) ); + $this->assertSame( '12', $this->get_row_value( $show_index_rows[0], 'Sub_part' ) ); + $this->assertSame( 'Title prefix note', $this->get_row_value( $show_index_rows[0], 'Index_comment' ) ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $show_index_sql ); + + $create_table_rows = $driver->query( 'SHOW CREATE TABLE `' . $table_name . '`' ); + $show_create_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $create_table_sql = (string) $this->get_row_value( $create_table_rows[0], 'Create Table' ); + $this->assertStringContainsString( '`title` varchar(80) NOT NULL DEFAULT \'untitled\' COMMENT \'Title note\'', $create_table_sql ); + $this->assertStringContainsString( 'KEY `title_prefix` (`title`(12)) COMMENT \'Title prefix note\'', $create_table_sql ); + $this->assertStringContainsString( "COMMENT='Projection map table note'", $create_table_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $show_create_sql ); + } + + /** + * Tests marker-looking user comments stay comments in catalog-backed metadata. + */ + public function test_real_pgsql_catalog_marker_user_comments_are_escaped_and_preserved(): void { + $driver = $this->create_driver(); + $table_name = 'wptests_marker_comment_collision'; + $table_literal = $driver->get_connection()->quote( $table_name ); + + $table_comment = '__wp_mysql_table_collation:not-base64 table note'; + $column_invalid_comment = '__wp_mysql_column_type:not-base64 column note'; + $column_valid_comment = '__wp_mysql_column_default:Q1VSUkVOVF9USU1FU1RBTVA='; + $column_type_comment = '__wp_mysql_column_type:ZGVjaW1hbCgxMCwyKSB1bnNpZ25lZA=='; + $index_invalid_comment = '__wp_mysql_index_type:not-base64 index note'; + $index_valid_comment = '__wp_mysql_index_type:QlRSRUU='; + + $this->assertSame( + 0, + $driver->query( + sprintf( + "CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(191) NOT NULL COMMENT '%s', + `body` text NOT NULL COMMENT '%s', + `amount` decimal(10,2) unsigned NOT NULL DEFAULT 0 COMMENT '%s', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `title_prefix` (`title`(10)) COMMENT '%s' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='%s'", + $table_name, + $column_invalid_comment, + $column_valid_comment, + $column_type_comment, + $index_invalid_comment, + $table_comment + ) + ) + ); + $this->assertSame( + 0, + $driver->query( + sprintf( + 'ALTER TABLE `%s` ADD FULLTEXT KEY `body_fulltext` (`body`) COMMENT "%s"', + $table_name, + $index_valid_comment + ) + ) + ); + + $table_rows = $driver->query( + "SELECT table_comment, table_collation + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$table_literal}" + ); + $this->assertCount( 1, $table_rows ); + $this->assertSame( $table_comment, $this->get_row_value( $table_rows[0], 'TABLE_COMMENT' ) ); + $this->assertSame( 'utf8mb4_bin', $this->get_row_value( $table_rows[0], 'TABLE_COLLATION' ) ); + + $column_rows = $driver->query( + "SELECT column_name, column_type, column_default, column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY ordinal_position" + ); + $this->assertSame( $column_invalid_comment, $this->get_row_value( $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'title' ), 'COLUMN_COMMENT' ) ); + $this->assertSame( $column_valid_comment, $this->get_row_value( $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'body' ), 'COLUMN_COMMENT' ) ); + $amount_column = $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'amount' ); + $this->assertSame( 'decimal(10,2) unsigned', $this->get_row_value( $amount_column, 'COLUMN_TYPE' ) ); + $this->assertSame( $column_type_comment, $this->get_row_value( $amount_column, 'COLUMN_COMMENT' ) ); + $this->assertSame( 'CURRENT_TIMESTAMP(6)', $this->get_row_value( $this->find_row_by_value( $column_rows, 'COLUMN_NAME', 'created_at' ), 'COLUMN_DEFAULT' ) ); + + $full_columns = $driver->query( 'SHOW FULL COLUMNS FROM `' . $table_name . '`' ); + $this->assertSame( $column_invalid_comment, $this->get_row_value( $this->find_row_by_value( $full_columns, 'Field', 'title' ), 'Comment' ) ); + $this->assertSame( $column_valid_comment, $this->get_row_value( $this->find_row_by_value( $full_columns, 'Field', 'body' ), 'Comment' ) ); + $this->assertSame( 'decimal(10,2) unsigned', $this->get_row_value( $this->find_row_by_value( $full_columns, 'Field', 'amount' ), 'Type' ) ); + + $statistics_rows = $driver->query( + "SELECT index_name, index_type, sub_part, index_comment + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY index_name, seq_in_index" + ); + $title_prefix_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'title_prefix' ); + $this->assertSame( '10', (string) $this->get_row_value( $title_prefix_statistic, 'SUB_PART' ) ); + $this->assertSame( $index_invalid_comment, $this->get_row_value( $title_prefix_statistic, 'INDEX_COMMENT' ) ); + $body_fulltext_statistic = $this->find_row_by_value( $statistics_rows, 'INDEX_NAME', 'body_fulltext' ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $body_fulltext_statistic, 'INDEX_TYPE' ) ); + $this->assertSame( $index_valid_comment, $this->get_row_value( $body_fulltext_statistic, 'INDEX_COMMENT' ) ); + + $show_index_rows = $driver->query( 'SHOW INDEX FROM `' . $table_name . '`' ); + $this->assertSame( $index_invalid_comment, $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'title_prefix' ), 'Index_comment' ) ); + $this->assertSame( 'FULLTEXT', $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'body_fulltext' ), 'Index_type' ) ); + $this->assertSame( $index_valid_comment, $this->get_row_value( $this->find_row_by_value( $show_index_rows, 'Key_name', 'body_fulltext' ), 'Index_comment' ) ); + + $show_table_status = $driver->query( "SHOW TABLE STATUS LIKE '{$table_name}'" ); + $this->assertCount( 1, $show_table_status ); + $this->assertSame( $table_comment, $this->get_row_value( $show_table_status[0], 'Comment' ) ); + + $create_table_rows = $driver->query( 'SHOW CREATE TABLE `' . $table_name . '`' ); + $create_table_sql = (string) $this->get_row_value( $create_table_rows[0], 'Create Table' ); + foreach ( array( $table_comment, $column_invalid_comment, $column_valid_comment, $column_type_comment, $index_invalid_comment, $index_valid_comment ) as $comment ) { + $this->assertStringContainsString( $comment, $create_table_sql ); + } + $this->assertStringNotContainsString( 'WP_MYSQL_COMMENT_ESCAPE:', $create_table_sql ); + } + + /** + * Tests real PostgreSQL direct information_schema.TABLES inline sources use catalogs. + */ + public function test_real_pgsql_direct_information_schema_tables_inline_source_uses_catalog(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL information_schema.TABLES inline source test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1018' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task1018_' . $suffix . '_tables_inline'; + $table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $table_literal = $driver->get_connection()->quote( $table_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE TABLE public.' . $table_sql . ' (id integer PRIMARY KEY, title text NOT NULL)' ); + + $rows = $driver->query( + "SELECT table_schema, table_name, table_type + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = {$table_literal} + ORDER BY table_name" + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL direct information_schema.TABLES inline source', + $backend_sql + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $database_name, $this->get_row_value( $rows[0], 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'BASE TABLE', $this->get_row_value( $rows[0], 'TABLE_TYPE' ) ); + $this->assertStringContainsString( 'FROM information_schema.tables t', $sql ); + $this->assertStringContainsString( 'pg_catalog.obj_description(pc.oid, \'pg_class\')', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', implode( "\n", $backend_sql ) ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1018' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1018' ) ); + } + } + + /** + * Tests real PostgreSQL direct information_schema joins use catalog column metadata. + */ + public function test_real_pgsql_direct_information_schema_mixed_application_join_uses_catalog_column_metadata(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL mixed information_schema join test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1018' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql, array $params = array() ) use ( &$logged_sql ): void { + $logged_sql[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task1018_' . $suffix . '_schema_names'; + $table_sql = WP_PostgreSQL_Connection::quote_identifier_value( $table_name ); + $table_literal = $driver->get_connection()->quote( $table_name ); + $backend_sql = array(); + + try { + $pdo->exec( 'CREATE TABLE public.' . $table_sql . ' (id integer PRIMARY KEY, db_name text NOT NULL)' ); + $stmt = $pdo->prepare( 'INSERT INTO public.' . $table_sql . ' (id, db_name) VALUES (1, ?)' ); + $stmt->execute( array( $database_name ) ); + + $rows = $driver->query( + "SELECT app.id, app.db_name, c.table_schema, c.table_name, c.column_name, c.data_type + FROM information_schema.columns AS c + JOIN `{$table_name}` AS app + ON app.db_name = c.table_schema + WHERE c.table_schema = DATABASE() + AND c.table_name = {$table_literal} + AND c.column_name = CONCAT('db', '_name') + ORDER BY app.id, c.ordinal_position" + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'real PostgreSQL direct information_schema mixed application join', + $backend_sql + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', (string) $this->get_row_value( $rows[0], 'id' ) ); + $this->assertSame( $database_name, $this->get_row_value( $rows[0], 'db_name' ) ); + $this->assertSame( $database_name, $this->get_row_value( $rows[0], 'TABLE_SCHEMA' ) ); + $this->assertSame( $table_name, $this->get_row_value( $rows[0], 'TABLE_NAME' ) ); + $this->assertSame( 'db_name', $this->get_row_value( $rows[0], 'COLUMN_NAME' ) ); + $this->assertSame( 'text', $this->get_row_value( $rows[0], 'DATA_TYPE' ) ); + $this->assertStringContainsString( 'FROM information_schema.columns c', $sql ); + $this->assertStringContainsString( '"' . $table_name . '" AS "app"', $sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', implode( "\n", $backend_sql ) ); + + $column_catalog_query = null; + foreach ( $logged_sql as $query ) { + $query_sql = (string) ( $query['sql'] ?? '' ); + if ( + false !== strpos( $query_sql, 'FROM information_schema.columns c' ) + && false !== strpos( $query_sql, 'ORDER BY c.ordinal_position' ) + ) { + $column_catalog_query = $query; + break; + } + } + + $this->assertNotNull( $column_catalog_query ); + $this->assertSame( array( 'public', $table_name ), $column_catalog_query['params'] ); + $this->assertStringContainsString( 'pg_catalog.col_description(pc.oid, pa.attnum)', $column_catalog_query['sql'] ); + foreach ( $logged_sql as $query ) { + } + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1018' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1018' ) ); + } + } + + /** + * Tests direct information_schema charset and collation SELECTs return MySQL-shaped rows. + */ + public function test_direct_information_schema_character_sets_and_collations_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + + $character_sets = $driver->query( 'SELECT * FROM INFORMATION_SCHEMA.CHARACTER_SETS ORDER BY CHARACTER_SET_NAME' ); + + $this->assertEquals( + array( + (object) array( + 'CHARACTER_SET_NAME' => 'binary', + 'DEFAULT_COLLATE_NAME' => 'binary', + 'DESCRIPTION' => 'Binary pseudo charset', + 'MAXLEN' => '1', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8', + 'DEFAULT_COLLATE_NAME' => 'utf8_general_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '3', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'DEFAULT_COLLATE_NAME' => 'utf8mb4_0900_ai_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '4', + ), + ), + $character_sets + ); + + $collations = $driver->query( 'SELECT * FROM INFORMATION_SCHEMA.COLLATIONS ORDER BY COLLATION_NAME' ); + + $this->assertEquals( + array( + (object) array( + 'COLLATION_NAME' => 'binary', + 'CHARACTER_SET_NAME' => 'binary', + 'ID' => '63', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_bin', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '83', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_general_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '33', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '192', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '255', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '0', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_bin', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '46', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '224', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + ), + $collations + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $defaults = $driver->query( + "SELECT cs.character_set_name, c.collation_name + FROM character_sets AS cs + JOIN collations AS c ON c.character_set_name = cs.character_set_name + WHERE c.is_default = 'Yes' + ORDER BY c.collation_name" + ); + + $this->assertEquals( + array( + (object) array( + 'CHARACTER_SET_NAME' => 'binary', + 'COLLATION_NAME' => 'binary', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + ), + ), + $defaults + ); + } + + /** + * Tests real PostgreSQL direct charset/collation information_schema relations use static compatibility rows. + */ + public function test_real_pgsql_direct_information_schema_charset_and_collation_relations_use_static_rows(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL direct information_schema charset/collation test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $backend_sql = array(); + + $character_sets = $driver->query( + "SELECT CHARACTER_SET_NAME, DEFAULT_COLLATE_NAME, DESCRIPTION, MAXLEN + FROM information_schema.character_sets + WHERE CHARACTER_SET_NAME LIKE 'utf8%' + ORDER BY CHARACTER_SET_NAME" + ); + $character_sets_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.character_sets real PostgreSQL', + $backend_sql + ); + + $this->assertCount( 2, $character_sets ); + $this->assertSame( + array( 'CHARACTER_SET_NAME', 'DEFAULT_COLLATE_NAME', 'DESCRIPTION', 'MAXLEN' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( 'utf8', $this->get_row_value( $character_sets[0], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8_general_ci', $this->get_row_value( $character_sets[0], 'DEFAULT_COLLATE_NAME' ) ); + $this->assertSame( 'UTF-8 Unicode', $this->get_row_value( $character_sets[0], 'DESCRIPTION' ) ); + $this->assertSame( '3', (string) $this->get_row_value( $character_sets[0], 'MAXLEN' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $character_sets[1], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $this->get_row_value( $character_sets[1], 'DEFAULT_COLLATE_NAME' ) ); + $this->assertSame( 'UTF-8 Unicode', $this->get_row_value( $character_sets[1], 'DESCRIPTION' ) ); + $this->assertSame( '4', (string) $this->get_row_value( $character_sets[1], 'MAXLEN' ) ); + $this->assertStringContainsString( "'utf8' AS \"CHARACTER_SET_NAME\"", $character_sets_sql ); + $this->assertStringContainsString( "'utf8_general_ci' AS \"DEFAULT_COLLATE_NAME\"", $character_sets_sql ); + $this->assertStringContainsString( "'utf8mb4' AS \"CHARACTER_SET_NAME\"", $character_sets_sql ); + $this->assertStringContainsString( "'utf8mb4_0900_ai_ci' AS \"DEFAULT_COLLATE_NAME\"", $character_sets_sql ); + $this->assertStringContainsString( 'UNION ALL', $character_sets_sql ); + + $collations = $driver->query( + "SELECT COLLATION_NAME, CHARACTER_SET_NAME, IS_DEFAULT, SORTLEN, PAD_ATTRIBUTE + FROM information_schema.collations + WHERE CHARACTER_SET_NAME = 'utf8mb4' + ORDER BY COLLATION_NAME" + ); + $collations_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.collations real PostgreSQL', + $backend_sql + ); + + $this->assertCount( 3, $collations ); + $this->assertSame( + array( 'COLLATION_NAME', 'CHARACTER_SET_NAME', 'IS_DEFAULT', 'SORTLEN', 'PAD_ATTRIBUTE' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $this->get_row_value( $collations[0], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $collations[0], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'Yes', $this->get_row_value( $collations[0], 'IS_DEFAULT' ) ); + $this->assertSame( '0', (string) $this->get_row_value( $collations[0], 'SORTLEN' ) ); + $this->assertSame( 'NO PAD', $this->get_row_value( $collations[0], 'PAD_ATTRIBUTE' ) ); + $this->assertSame( 'utf8mb4_bin', $this->get_row_value( $collations[1], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $collations[1], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( '', $this->get_row_value( $collations[1], 'IS_DEFAULT' ) ); + $this->assertSame( '1', (string) $this->get_row_value( $collations[1], 'SORTLEN' ) ); + $this->assertSame( 'PAD SPACE', $this->get_row_value( $collations[1], 'PAD_ATTRIBUTE' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $collations[2], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $collations[2], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( '', $this->get_row_value( $collations[2], 'IS_DEFAULT' ) ); + $this->assertSame( '8', (string) $this->get_row_value( $collations[2], 'SORTLEN' ) ); + $this->assertSame( 'PAD SPACE', $this->get_row_value( $collations[2], 'PAD_ATTRIBUTE' ) ); + $this->assertStringContainsString( "'utf8mb4_0900_ai_ci' AS \"COLLATION_NAME\"", $collations_sql ); + $this->assertStringContainsString( "'utf8mb4_bin' AS \"COLLATION_NAME\"", $collations_sql ); + $this->assertStringContainsString( "'utf8mb4_unicode_ci' AS \"COLLATION_NAME\"", $collations_sql ); + $this->assertStringContainsString( 'UNION ALL', $collations_sql ); + + $applicability = $driver->query( + "SELECT COLLATION_NAME, CHARACTER_SET_NAME + FROM information_schema.collation_character_set_applicability + WHERE CHARACTER_SET_NAME = 'utf8mb4' + ORDER BY COLLATION_NAME" + ); + $applicability_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->collect_last_postgresql_queries( + $driver, + 'direct information_schema.collation_character_set_applicability real PostgreSQL', + $backend_sql + ); + + $this->assertCount( 3, $applicability ); + $this->assertSame( array( 'COLLATION_NAME', 'CHARACTER_SET_NAME' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $this->get_row_value( $applicability[0], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $applicability[0], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8mb4_bin', $this->get_row_value( $applicability[1], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $applicability[1], 'CHARACTER_SET_NAME' ) ); + $this->assertSame( 'utf8mb4_unicode_ci', $this->get_row_value( $applicability[2], 'COLLATION_NAME' ) ); + $this->assertSame( 'utf8mb4', $this->get_row_value( $applicability[2], 'CHARACTER_SET_NAME' ) ); + $this->assertStringContainsString( "'utf8mb4_0900_ai_ci' AS \"COLLATION_NAME\"", $applicability_sql ); + $this->assertStringContainsString( "'utf8mb4_bin' AS \"COLLATION_NAME\"", $applicability_sql ); + $this->assertStringContainsString( "'utf8mb4_unicode_ci' AS \"COLLATION_NAME\"", $applicability_sql ); + $this->assertStringContainsString( 'UNION ALL', $applicability_sql ); + + foreach ( array( $character_sets_sql, $collations_sql, $applicability_sql ) as $sql ) { + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_available_extensions', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_stat_activity', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_get_keywords()', $sql ); + $this->assertStringNotContainsString( 'pg_catalog.pg_tablespace', $sql ); + $this->assertStringNotContainsString( 'information_schema.columns c', $sql ); + } + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } + + /** + * Tests unsupported simple DML subquery predicates fail before backend execution. + */ + public function test_simple_dml_subquery_predicates_fail_closed(): void { + $queries = array( + 'UPDATE wptests_options SET option_value = "updated" WHERE option_name IN ( + SELECT option_name FROM wptests_options + )' => 'Unsupported UPDATE statement.', + 'DELETE FROM wptests_options WHERE option_name IN ( + SELECT option_name FROM wptests_options + )' => 'Unsupported DELETE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('wptests_options', 'before')" ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported simple DML subquery to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ambiguous unqualified information_schema columns are unresolved. + */ + public function test_direct_information_schema_ambiguous_unqualified_column_reference_returns_null(): void { + $driver = ( new ReflectionClass( WP_PostgreSQL_Driver::class ) )->newInstanceWithoutConstructor(); + $get_reference = Closure::bind( + function ( array $tokens, array $context ): ?array { + return $this->get_direct_information_schema_column_reference_for_expression( $tokens, 0, 1, $context ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $tokens = array( + new WP_MySQL_Token( WP_MySQL_Lexer::NAME_SYMBOL, 0, 10, 'TABLE_NAME', false ), + ); + $context = array( + 'sources' => array( + array( + 'alias' => 't', + 'column_map' => array( 'table_name' => 'TABLE_NAME' ), + 'source_start' => 0, + 'source_end' => 1, + ), + array( + 'alias' => 'c', + 'column_map' => array( 'table_name' => 'TABLE_NAME' ), + 'source_start' => 2, + 'source_end' => 3, + ), + ), + 'using_columns' => array(), + ); + + $this->assertNull( $get_reference( $tokens, $context ) ); + } + + /** + * 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 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() + ); + + $this->assertFalse( $this->postgresql_relation_exists( $driver, 'transaction_test' ) ); + + $this->assertSame( 0, $driver->query( 'COMMIT' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests public SAVEPOINT statements return MySQL-compatible results. + */ + public function test_mysql_savepoint_statements_use_public_query_path(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $driver->query( 'CREATE TABLE savepoint_public (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'INSERT INTO savepoint_public VALUES (1)' ); + $driver->query( 'SELECT 1 AS warm_read' ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK TO SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s2' ) ); + $driver->query( 'INSERT INTO savepoint_public VALUES (2)' ); + $this->assertSame( 0, $driver->query( 'ROLLBACK WORK TO s2' ) ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s2"', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s2' ) ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s3' ) ); + $driver->query( 'INSERT INTO savepoint_public VALUES (3)' ); + $this->assertSame( 0, $driver->query( 'ROLLBACK WORK TO SAVEPOINT s3' ) ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s3"', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s3' ) ); + + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'RELEASE SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS row_count FROM savepoint_public' ); + + $this->assertSame( '0', $rows[0]->row_count ); + } + + /** + * Tests unsupported savepoint-family statements fail before raw backend execution. + */ + public function test_unsupported_mysql_savepoint_statements_fail_closed_without_backend_execution(): void { + $cases = array( + 'RELEASE s', + 'ROLLBACK SAVEPOINT s', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $driver->query( 'SAVEPOINT s' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SAVEPOINT statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SAVEPOINT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests real PostgreSQL TRUNCATE TABLE restarts identity. + */ + public function test_real_pgsql_truncate_table_restarts_identity(): void { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run the real PostgreSQL TRUNCATE identity test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $pdo->exec( 'SET search_path TO public' ); + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1036_truncate' ); + + $database_name = (string) $pdo->query( 'SELECT current_database()' )->fetchColumn(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + $logged_sql = array(); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, $database_name ); + $suffix = strtolower( bin2hex( random_bytes( 4 ) ) ); + $table_name = 'task1036_truncate_' . $suffix; + $qualified_table = 'task1036_truncate_' . $suffix . '_qualified'; + $backend_sql = array(); + $create_table = static function ( WP_PostgreSQL_Driver $driver, string $table_name ): int { + return $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `label` varchar(64) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + $table_name + ) + ); + }; + + try { + $this->assertSame( 0, $create_table( $driver, $table_name ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE unqualified TRUNCATE table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`label`) VALUES ('before')", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert first unqualified TRUNCATE row', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`label`) VALUES ('again')", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert second unqualified TRUNCATE row', + $backend_sql + ); + + $rows = $driver->query( sprintf( 'SELECT `id`, `label` FROM `%s` ORDER BY `id`', $table_name ) ); + $this->collect_last_postgresql_queries( + $driver, + 'read unqualified rows before TRUNCATE', + $backend_sql + ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'label' => 'before', + ), + (object) array( + 'id' => '2', + 'label' => 'again', + ), + ), + $rows + ); + + $this->assertSame( 0, $driver->query( sprintf( 'TRUNCATE TABLE `%s`', $table_name ) ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( + 'TRUNCATE TABLE "' . $table_name . '" RESTART IDENTITY', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'unqualified TRUNCATE', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`label`) VALUES ('after')", $table_name ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert unqualified row after TRUNCATE', + $backend_sql + ); + $rows = $driver->query( sprintf( 'SELECT `id`, `label` FROM `%s`', $table_name ) ); + $this->collect_last_postgresql_queries( + $driver, + 'read unqualified rows after TRUNCATE', + $backend_sql + ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'label' => 'after', + ), + ), + $rows + ); + + $this->assertSame( 0, $create_table( $driver, $qualified_table ) ); + $this->collect_last_postgresql_queries( + $driver, + 'CREATE qualified TRUNCATE table', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`label`) VALUES ('qualified_before')", $qualified_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert first qualified TRUNCATE row', + $backend_sql + ); + $this->assertSame( + 1, + $driver->query( sprintf( "INSERT INTO `%s` (`label`) VALUES ('qualified_again')", $qualified_table ) ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert second qualified TRUNCATE row', + $backend_sql + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->collect_last_postgresql_queries( + $driver, + 'USE information_schema before qualified TRUNCATE', + $backend_sql + ); + + $this->assertSame( + 0, + $driver->query( + sprintf( + 'TRUNCATE TABLE `%s`.`%s`', + $database_name, + $qualified_table + ) + ) + ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( + 'TRUNCATE TABLE "' . $qualified_table . '" RESTART IDENTITY', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'main database-qualified TRUNCATE after USE information_schema', + $backend_sql + ); + + $this->assertSame( + 1, + $driver->query( + sprintf( + "INSERT INTO `%s`.`%s` (`label`) VALUES ('qualified_after')", + $database_name, + $qualified_table + ) + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'insert qualified row after TRUNCATE', + $backend_sql + ); + $rows = $driver->query( + sprintf( + 'SELECT `id`, `label` FROM `%s`.`%s`', + $database_name, + $qualified_table + ) + ); + $this->collect_last_postgresql_queries( + $driver, + 'read qualified rows after TRUNCATE', + $backend_sql + ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'label' => 'qualified_after', + ), + ), + $rows + ); + + $all_logged_sql = implode( "\n", $logged_sql ); + $this->assertStringContainsString( 'TRUNCATE TABLE "' . $table_name . '" RESTART IDENTITY', $all_logged_sql ); + $this->assertStringContainsString( 'TRUNCATE TABLE "' . $qualified_table . '" RESTART IDENTITY', $all_logged_sql ); + $this->assertStringNotContainsString( 'DELETE FROM', $all_logged_sql ); + $this->assertStringNotContainsString( 'sqlite' . '_sequence', $all_logged_sql ); + $this->assertStringNotContainsString( '__wp_mysql_information_schema', $all_logged_sql ); + $this->assertStringNotContainsString( 'CREATE OR REPLACE VIEW', $all_logged_sql ); + $this->assertGreaterThan( 0, count( $backend_sql ) ); + } finally { + $this->drop_public_pgsql_tables_with_prefix( $pdo, 'task1036_truncate' ); + $this->assertSame( array(), $this->get_public_pgsql_tables_with_prefix( $pdo, 'task1036_truncate' ) ); + } + } + + /** + * Tests unsupported CREATE TABLE ... SELECT variants fail without backend execution. + */ + public function test_unsupported_create_table_select_constructs_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'partition before select' => 'CREATE TABLE ctas_partitioned PARTITION BY HASH(id) PARTITIONS 2 AS SELECT 1 AS id', + ); + + foreach ( $queries as $label => $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE TABLE statement to throw for ' . $label . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage(), $label ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $label ); + } + } + } + + /** + * Tests CREATE VIEW with a MySQL column list is translated and queryable. + */ + public function test_create_view_with_column_list_translates_and_reads_rows(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE view_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO view_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE VIEW view_copy (`item_id`, `item_name`) AS SELECT `id`, `name` FROM `view_source` WHERE `id` > 1' ) + ); + $this->assertSame( + 'CREATE VIEW "view_copy" ("item_id", "item_name") AS SELECT "id", "name" FROM "view_source" WHERE "id" > 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT item_id, item_name FROM view_copy ORDER BY item_id' ); + $this->assertEquals( + array( + (object) array( + 'item_id' => '2', + 'item_name' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests CREATE OR REPLACE VIEW and ALTER VIEW emit PostgreSQL CREATE OR REPLACE VIEW. + */ /** + * Tests DROP VIEW accepts IF EXISTS, multiple targets, and MySQL RESTRICT no-op. + */ + public function test_drop_view_if_exists_accepts_multiple_targets_and_restrict(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE VIEW drop_view_one AS SELECT 1 AS id' ); + $driver->query( 'CREATE VIEW drop_view_two AS SELECT 2 AS id' ); + + $this->assertSame( 0, $driver->query( 'DROP VIEW IF EXISTS drop_view_one, drop_view_two RESTRICT' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP VIEW IF EXISTS "drop_view_one"', + 'params' => array(), + ), + array( + 'sql' => 'DROP VIEW IF EXISTS "drop_view_two"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests main database-qualified VIEW DDL targets are accepted. + */ + public function test_view_ddl_accepts_main_database_qualified_targets(): void { + $driver = $this->create_driver( 'wp' ); + + $this->assertSame( 0, $driver->query( 'CREATE VIEW wp.qualified_view AS SELECT 1 AS id' ) ); + $this->assertSame( + 'CREATE VIEW "qualified_view" AS SELECT 1 AS id', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( 'DROP VIEW wp.qualified_view CASCADE' ) ); + $this->assertSame( + 'DROP VIEW "qualified_view"', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported MySQL-only VIEW clauses fail without backend execution. + */ + public function test_unsupported_view_ddl_clauses_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'CREATE ALGORITHM = MERGE VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE DEFINER = root@localhost VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE SQL SECURITY DEFINER VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE VIEW plugin_view AS SELECT 1 AS id WITH CHECK OPTION' => 'Unsupported CREATE VIEW statement.', + 'CREATE VIEW plugin_view AS SELECT 1 AS id WITH LOCAL' => 'Unsupported CREATE VIEW statement.', + 'ALTER VIEW plugin_view AS SELECT 1 AS id WITH LOCAL CHECK OPTION' => 'Unsupported ALTER VIEW statement.', + 'CREATE VIEW information_schema.plugin_view AS SELECT 1 AS id' => 'Unsupported information_schema query.', + 'DROP VIEW information_schema.plugin_view' => 'Unsupported information_schema query.', + 'CREATE VIEW plugin_view (id,) AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported VIEW DDL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported CREATE statement families fail without backend execution. + */ + public function test_unsupported_create_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'CREATE DATABASE plugin_db' => 'Unsupported CREATE DATABASE statement.', + 'CREATE SCHEMA plugin_schema' => 'Unsupported CREATE DATABASE statement.', + 'CREATE TRIGGER plugin_trigger BEFORE INSERT ON plugin_table FOR EACH ROW SET NEW.id = 1' => 'Unsupported CREATE TRIGGER statement.', + 'CREATE EVENT plugin_event ON SCHEDULE EVERY 1 DAY DO SELECT 1' => 'Unsupported CREATE EVENT statement.', + 'CREATE USER plugin_user' => 'Unsupported CREATE USER statement.', + 'CREATE SERVER plugin_server FOREIGN DATA WRAPPER mysql OPTIONS (HOST "localhost")' => 'Unsupported CREATE SERVER statement.', + 'CREATE LOGFILE GROUP plugin_logfile ADD UNDOFILE "undo.dat"' => 'Unsupported CREATE LOGFILE statement.', + 'CREATE SPATIAL REFERENCE SYSTEM 4326 NAME "WGS 84" ORGANIZATION "EPSG" IDENTIFIED BY 4326 DEFINITION "GEOGCS[]"' => 'Unsupported CREATE SPATIAL REFERENCE SYSTEM statement.', + 'CREATE TABLESPACE plugin_tablespace ADD DATAFILE "plugin.ibd"' => 'Unsupported CREATE TABLESPACE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported DROP statement families fail without backend execution. + */ + public function test_unsupported_drop_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'DROP DATABASE plugin_db' => 'Unsupported DROP DATABASE statement.', + 'DROP SCHEMA IF EXISTS plugin_schema' => 'Unsupported DROP DATABASE statement.', + 'DROP PROCEDURE plugin_procedure' => 'Unsupported DROP PROCEDURE statement.', + 'DROP FUNCTION plugin_function' => 'Unsupported DROP FUNCTION statement.', + 'DROP TRIGGER plugin_trigger' => 'Unsupported DROP TRIGGER statement.', + 'DROP EVENT IF EXISTS plugin_event' => 'Unsupported DROP EVENT statement.', + 'DROP USER plugin_user' => 'Unsupported DROP USER statement.', + 'DROP ROLE plugin_role' => 'Unsupported DROP ROLE statement.', + 'DROP SPATIAL REFERENCE SYSTEM 4326' => 'Unsupported DROP SPATIAL REFERENCE SYSTEM statement.', + 'DROP TABLESPACE plugin_tablespace' => 'Unsupported DROP TABLESPACE statement.', + 'DROP UNDO TABLESPACE plugin_undo' => 'Unsupported DROP UNDO TABLESPACE statement.', + 'DROP SERVER plugin_server' => 'Unsupported DROP SERVER statement.', + 'DROP LOGFILE GROUP plugin_logfile' => 'Unsupported DROP LOGFILE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DROP statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported routine statements fail before backend execution. + */ + public function test_unsupported_routine_statements_fail_closed_before_backend_execution(): void { + $queries = array( + 'CREATE PROCEDURE plugin_procedure() BEGIN SELECT 1; END' => 'Unsupported CREATE PROCEDURE statement.', + 'DROP PROCEDURE IF EXISTS plugin_procedure' => 'Unsupported DROP PROCEDURE statement.', + 'SHOW CREATE PROCEDURE plugin_procedure' => 'Unsupported SHOW statement.', + 'CALL plugin_procedure()' => 'Unsupported CALL statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported routine statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported ALTER/RENAME statement families fail without backend execution. + */ + public function test_unsupported_alter_and_rename_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'ALTER DATABASE plugin_db CHARACTER SET utf8mb4' => 'Unsupported ALTER DATABASE statement.', + 'ALTER EVENT plugin_event DISABLE' => 'Unsupported ALTER EVENT statement.', + 'ALTER LOGFILE GROUP plugin_logfile ADD UNDOFILE "u.dat"' => 'Unsupported ALTER LOGFILE statement.', + 'ALTER SERVER plugin_server OPTIONS (HOST "localhost")' => 'Unsupported ALTER SERVER statement.', + 'ALTER TABLESPACE plugin_tablespace ADD DATAFILE "t.ibd"' => 'Unsupported ALTER TABLESPACE statement.', + 'ALTER UNDO TABLESPACE plugin_undo SET INACTIVE' => 'Unsupported ALTER UNDO TABLESPACE statement.', + 'RENAME USER old_user TO new_user' => 'Unsupported RENAME USER statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER/RENAME statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests main database-qualified table names work for a focused basic operation slice. + */ + public function test_main_database_qualified_table_names_work_for_basic_table_operations(): void { + $driver = $this->create_driver( 'wp' ); + + $this->assertSame( 0, $driver->query( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ) ); + $this->assertStringStartsWith( 'CREATE TABLE "t"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO wp.t (id) VALUES (1)' ) ); + $this->assertSame( 'INSERT INTO "t" ("id") VALUES (1)', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $rows ); + $this->assertSame( 'SELECT * FROM t', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'UPDATE wp.t SET id = 2' ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $rows ); + + $this->assertSame( 1, $driver->query( 'DELETE FROM wp.t WHERE id = 2' ) ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests malformed main database-qualified CREATE TABLE targets fail closed. + */ + public function test_create_table_rejects_extra_qualified_main_database_target(): void { + $driver = $this->create_driver( 'wp' ); + + try { + $driver->query( 'CREATE TABLE wp.other.t (id INT)' ); + $this->fail( 'Expected extra qualified CREATE TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests UNLOCK TABLES forms are MySQL compatibility no-ops. + */ + public function test_mysql_unlock_tables_statements_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + + foreach ( array( 'UNLOCK TABLES', 'UNLOCK TABLE' ) as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES forms validate existing tables and then no-op. + */ + public function test_mysql_lock_tables_existing_tables_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_table_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_two (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_three (id INTEGER)' ); + + $cases = array( + 'LOCK TABLES lock_table_one READ', + 'LOCK TABLES lock_table_one WRITE', + 'LOCK TABLE lock_table_one READ', + 'LOCK TABLE lock_table_one WRITE', + 'LOCK TABLES lock_table_one READ, lock_table_two READ, lock_table_three WRITE', + ); + + foreach ( $cases as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES accepts main database-qualified table references. + */ + public function test_mysql_lock_tables_accepts_main_database_qualified_table_references(): void { + $driver = $this->create_driver( 'wp' ); + $driver->query( 'CREATE TABLE lock_qualified_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES wp.lock_qualified_table READ' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES accepts existing temporary tables. + */ + public function test_mysql_lock_tables_accepts_existing_temporary_tables(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TEMPORARY TABLE lock_temp_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_temp_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES missing targets fail before raw backend execution. + */ + public function test_mysql_lock_tables_missing_table_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_existing_table (id INTEGER)' ); + + try { + $driver->query( 'LOCK TABLES lock_existing_table READ, lock_missing_table WRITE' ); + $this->fail( 'Expected missing LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Table 'wptests.lock_missing_table' doesn't exist", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES information_schema targets fail closed. + */ + public function test_mysql_lock_tables_information_schema_targets_fail_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'LOCK TABLES information_schema.tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES under USE information_schema fails closed. + */ + public function test_mysql_lock_tables_after_use_information_schema_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE tables (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'LOCK TABLES tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES accepts SQLite-compatible lock modes and aliases. + */ + public function test_mysql_lock_tables_accepts_read_local_low_priority_write_and_aliases(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_mode_table (id INTEGER)' ); + + $queries = array( + 'LOCK TABLES lock_mode_table READ LOCAL', + 'LOCK TABLES lock_mode_table LOW_PRIORITY WRITE', + 'LOCK TABLES lock_mode_table AS lock_alias READ', + 'LOCK TABLES lock_mode_table implicit_alias READ LOCAL, lock_mode_table LOW_PRIORITY WRITE', + ); + + foreach ( $queries as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests malformed LOCK TABLES modes fail before raw backend execution. + */ + public function test_mysql_lock_tables_unsupported_modes_fail_before_backend_execution(): void { + $queries = array( + 'LOCK TABLES lock_mode_table LOW_PRIORITY READ', + 'LOCK TABLES lock_mode_table READ LOCAL LOCAL', + 'LOCK TABLES lock_mode_table WRITE LOCAL', + 'LOCK TABLES lock_mode_table AS READ', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_mode_table (id INTEGER)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LOCK TABLES mode to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests LOCK/UNLOCK TABLES no-ops do not commit user transactions. + */ + public function test_mysql_lock_tables_noop_does_not_commit_user_transaction(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_transaction_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $this->assertSame( 1, $driver->query( 'INSERT INTO lock_transaction_table (id) VALUES (1)' ) ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_transaction_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'UNLOCK TABLES' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK' ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS lock_row_count FROM lock_transaction_table' ); + $this->assertSame( 0, (int) $rows[0]->lock_row_count ); + } + + /** + * Tests safe FLUSH statements used by admin tooling are MySQL compatibility no-ops. + */ + public function test_mysql_flush_tables_and_privileges_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'FLUSH TABLES', + 'FLUSH TABLE', + 'FLUSH LOCAL TABLES', + 'FLUSH NO_WRITE_TO_BINLOG TABLES', + 'FLUSH PRIVILEGES', + 'FLUSH LOCAL PRIVILEGES', + ) as $query + ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + } + } + + /** + * Tests unsafe or stateful FLUSH forms still fail before backend execution. + */ + public function test_unsupported_mysql_flush_statements_fail_closed_without_backend_execution(): void { + $queries = array( + 'FLUSH TABLES WITH READ LOCK', + 'FLUSH STATUS', + 'FLUSH BINARY LOGS', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported FLUSH statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported FLUSH statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests the emulated MySQL session SQL mode can be selected. + */ + public function test_select_session_sql_mode_returns_emulated_driver_state(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE,NO_AUTO_VALUE_ON_ZERO' ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode;' ); + + $this->assertSame( 'IGNORE_SPACE,NO_AUTO_VALUE_ON_ZERO', $rows[0]->{'@@SESSION.sql_mode'} ); + $this->assertSame( 'SELECT @@SESSION.sql_mode;', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( '@@SESSION.sql_mode', $driver->get_last_column_meta()[0]['name'] ); + } + + /** + * Tests the PostgreSQL driver defaults to the same MySQL SQL modes as SQLite. + */ + public function test_default_sql_mode_matches_sqlite_backend_defaults(): void { + $driver = $this->create_driver(); + + $expected = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + + $this->assertSame( $expected, $driver->get_sql_mode() ); + + $rows = $driver->query( 'SELECT @@sql_mode' ); + $this->assertSame( $expected, $rows[0]->{'@@sql_mode'} ); + + $rows = $driver->query( "SHOW VARIABLES LIKE 'sql_mode'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( $expected, $rows[0]->Value ); + } + + /** + * Tests supported SQL mode SET syntaxes normalize and report emulated state. + */ + public function test_sql_mode_set_syntaxes_update_emulated_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = "ERROR_FOR_DIVISION_BY_ZERO"' ) ); + $this->assertSame( 'ERROR_FOR_DIVISION_BY_ZERO', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET @@sql_mode = 'NO_ENGINE_SUBSTITUTION'" ) ); + $this->assertSame( 'NO_ENGINE_SUBSTITUTION', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'NO_ZERO_DATE'" ) ); + $this->assertSame( 'NO_ZERO_DATE', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET @@SESSION.sql_mode = 'NO_ZERO_IN_DATE'" ) ); + $rows = $driver->query( 'SELECT @@SESSION.sql_mode' ); + $this->assertSame( 'NO_ZERO_IN_DATE', $rows[0]->{'@@SESSION.sql_mode'} ); + + $this->assertSame( 0, $driver->query( 'SET @@session.SQL_mode = "only_full_group_by"' ) ); + $rows = $driver->query( 'SELECT @@session.SQL_mode' ); + $this->assertSame( 'ONLY_FULL_GROUP_BY', $rows[0]->{'@@session.SQL_mode'} ); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = DEFAULT' ) ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $driver->get_sql_mode() + ); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = 0' ) ); + $this->assertSame( '', $driver->get_sql_mode() ); + } + + /** + * Tests SQL mode user-variable save/restore flows. + */ + public function test_sql_mode_can_be_saved_and_restored_through_user_variables(): void { + $driver = $this->create_driver(); + $initial_mode = $driver->get_sql_mode(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_mode = @@SESSION.sql_mode' ) ); + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( 'SET SESSION sql_mode = @old_sql_mode' ) ); + $this->assertSame( $initial_mode, $driver->get_sql_mode() ); + } + + /** + * Tests SQL-mode case conversion expressions update PostgreSQL emulated state. + */ + public function test_sql_mode_case_conversion_expression_assignments_update_emulated_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + + try { + $driver->query( 'SET sql_mode = LOWER("NO_ZERO_DATE")' ); + $this->fail( 'Expected ANSI_QUOTES double-quoted SET expression to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + } + + $this->assertSame( 0, $driver->query( "SET sql_mode = LOWER('NO_ZERO_DATE')" ) ); + $this->assertSame( 'NO_ZERO_DATE', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = UCASE(REPLACE(@@sql_mode, 'NO_ZERO_DATE', 'ansi_quotes,no_backslash_escapes'))" ) ); + $this->assertSame( 'ANSI_QUOTES,NO_BACKSLASH_ESCAPES', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = LCASE('PIPES_AS_CONCAT')" ) ); + $this->assertSame( 'PIPES_AS_CONCAT', $driver->get_sql_mode() ); + } + + /** + * Tests SQL-mode expressions supported by SQLite drive PostgreSQL zero-date behavior. + */ + public function test_sql_mode_expression_assignments_drive_zero_date_behavior(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = REPLACE(@@sql_mode, 'NO_ZERO_DATE', '')" ) ); + $this->assertFalse( $driver->is_sql_mode_active( 'NO_ZERO_DATE' ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = CONCAT(@@SESSION.sql_mode, ',NO_ZERO_DATE')" ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'NO_ZERO_DATE' ) ); + + try { + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (2, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + $this->fail( 'Expected zero date to be rejected after expression re-enabled NO_ZERO_DATE.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( 0, $driver->query( "SET sql_mode = (SELECT REPLACE(@@sql_mode, 'NO_ZERO_IN_DATE', '') FROM DUAL)" ) ); + $this->assertFalse( $driver->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (3, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 3' ); + $this->assertSame( '2020-00-15 14:15:27', $rows[0]->post_modified ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = (SELECT CONCAT(@@sql_mode, ',NO_ZERO_IN_DATE'))" ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ); + + try { + $driver->query( "UPDATE `wptests_posts` SET `post_modified` = '2020-00-16 14:15:27' WHERE `ID` = 3" ); + $this->fail( 'Expected zero-in-date to be rejected after expression re-enabled NO_ZERO_IN_DATE.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '2020-00-16 14:15:27'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported SQL-mode SET expressions fail before backend execution. + */ + public function test_unsupported_sql_mode_expression_assignment_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SET sql_mode = SUBSTRING(@@sql_mode, 1, 10)' ); + $this->fail( 'Expected unsupported SQL-mode expression to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ANSI_QUOTES affects PostgreSQL query translation. + */ + public function test_ansi_quotes_sql_mode_treats_double_quoted_text_as_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_title TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_title) VALUES (1, \'Hello\')' ); + + $literal = $driver->query( 'SELECT "post_title" AS value' ); + $this->assertSame( 'post_title', $literal[0]->value ); + $this->assertSame( + "SELECT 'post_title' AS value", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $rows = $driver->query( 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'Hello', $rows[0]->post_title ); + $this->assertSame( + 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests composite SQL modes expand and affect PostgreSQL tokenization. + */ + public function test_composite_sql_modes_expand_and_drive_postgresql_tokenization(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI'" ) ); + $this->assertSame( + 'REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ONLY_FULL_GROUP_BY', + $driver->get_sql_mode() + ); + $this->assertTrue( $driver->is_sql_mode_active( 'ANSI_QUOTES' ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'PIPES_AS_CONCAT' ) ); + + $driver->query( 'CREATE TABLE "wptests_ansi_mode" ("ID" INTEGER PRIMARY KEY, "post title" TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO "wptests_ansi_mode" ("ID", "post title") VALUES (1, \'Hello\')' ); + $rows = $driver->query( 'SELECT "post title" FROM "wptests_ansi_mode" WHERE "ID" = 1' ); + $this->assertSame( 'Hello', $rows[0]->{'post title'} ); + $this->assertSame( + 'SELECT "post title" FROM "wptests_ansi_mode" WHERE "ID" = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT 'a' || 'b' AS value" ); + $this->assertSame( 'ab', $rows[0]->value ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'TRADITIONAL'" ) ); + $this->assertSame( + 'STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', + $driver->get_sql_mode() + ); + $this->assertFalse( $driver->is_sql_mode_active( 'ANSI_QUOTES' ) ); + + $rows = $driver->query( 'SELECT "post title" AS value' ); + $this->assertSame( 'post title', $rows[0]->value ); + $this->assertSame( + "SELECT 'post title' AS value", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests NO_BACKSLASH_ESCAPES changes PostgreSQL string literal translation. + */ + public function test_no_backslash_escapes_sql_mode_changes_postgresql_string_literals(): void { + $driver = $this->create_driver(); + $backslash = chr( 92 ); + $query = "SELECT '{$backslash}n' AS value"; + + $driver->set_sql_mode( '' ); + $rows = $driver->query( $query ); + $this->assertSame( "\n", $rows[0]->value ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'" ) ); + $rows = $driver->query( $query ); + $this->assertSame( $backslash . 'n', $rows[0]->value ); + } + + /** + * Tests NO_BACKSLASH_ESCAPES disables PostgreSQL's default LIKE backslash escape. + */ + public function test_no_backslash_escapes_sql_mode_changes_postgresql_like_patterns(): void { + $driver = $this->create_driver(); + $backslash = chr( 92 ); + $query = "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' OR value NOT LIKE 'def{$backslash}%'"; + + $driver->set_sql_mode( '' ); + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' OR value NOT LIKE 'def{$backslash}%'", + $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ) + ); + + $driver->set_sql_mode( 'NO_BACKSLASH_ESCAPES' ); + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '' OR value NOT LIKE 'def{$backslash}%' ESCAPE ''", + $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ) + ); + + $cast_like_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CAST(meta_value AS DECIMAL(10,2)) LIKE '12{$backslash}_' AS matched FROM wptests_postmeta" + ); + $this->assertStringContainsString( "LIKE '12{$backslash}_' ESCAPE '' AS matched", $cast_like_sql ); + $this->assertStringNotContainsString( "ESCAPE '' ESCAPE ''", $cast_like_sql ); + + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '!'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '!'" + ) + ); + } + + /** + * Tests PIPES_AS_CONCAT switches || from logical OR to concatenation. + */ + public function test_pipes_as_concat_sql_mode_changes_postgresql_double_pipe_translation(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( '' ); + $rows = $driver->query( 'SELECT 0 || 1 AS value' ); + $this->assertSame( '1', (string) $rows[0]->value ); + $this->assertSame( 'SELECT 0 OR 1 AS value', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'PIPES_AS_CONCAT'" ) ); + $rows = $driver->query( "SELECT 'a' || 'b' AS value" ); + $this->assertSame( 'ab', $rows[0]->value ); + $this->assertSame( "SELECT 'a' || 'b' AS value", $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests built-in MySQL version variables are selected from emulated state. + */ + public function test_select_builtin_version_variables_returns_mysql_compatible_values_without_backend_queries(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT @@version, @@version_comment' ); + + $this->assertSame( '8.0.38', $rows[0]->{'@@version'} ); + $this->assertSame( 'MySQL Community Server - GPL', $rows[0]->{'@@version_comment'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( '@@version', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( '@@version_comment', $driver->get_last_column_meta()[1]['name'] ); + } + + /** + * Tests emulated MySQL variables support explicit and implicit projection aliases. + */ + public function test_select_system_variables_support_mysql_projection_aliases_without_backend_queries(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE' ); + + $rows = $driver->query( "SELECT @@SESSION.sql_mode AS mode, @@version version_alias, @@version_comment AS 'comment_alias'" ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( '8.0.38', $rows[0]->version_alias ); + $this->assertSame( 'MySQL Community Server - GPL', $rows[0]->comment_alias ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'mode', 'version_alias', 'comment_alias' ), + array_map( + static function ( $column ) { + return $column['name']; + }, + $driver->get_last_column_meta() + ) + ); + } + + /** + * Tests MySQL variables inside otherwise ordinary SELECT projections are translated. + */ + public function test_mysql_variables_in_mixed_select_projection_are_translated(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE' ); + $this->assertSame( 0, $driver->query( "SET @label = 'first'" ) ); + + $query = 'SELECT 1 AS n, @@SESSION.sql_mode AS mode, @label AS label'; + $rows = $driver->query( $query ); + + $this->assertSame( '1', $rows[0]->n ); + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( 'first', $rows[0]->label ); + $this->assertSame( + "SELECT 1 AS n, 'IGNORE_SPACE' AS mode, 'first' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode AS mode, 1 AS n, @label AS label' ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( '1', $rows[0]->n ); + $this->assertSame( 'first', $rows[0]->label ); + $this->assertSame( + "SELECT 'IGNORE_SPACE' AS mode, 1 AS n, 'first' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode AS mode FROM DUAL' ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( + "SELECT 'IGNORE_SPACE' AS mode", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 0, $driver->query( "SET @label = 'second'" ) ); + + $rows = $driver->query( $query ); + + $this->assertSame( 'ANSI_QUOTES', $rows[0]->mode ); + $this->assertSame( 'second', $rows[0]->label ); + $this->assertSame( + "SELECT 1 AS n, 'ANSI_QUOTES' AS mode, 'second' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported system variables inside mixed SELECT projections fail before PDO. + */ + public function test_unsupported_system_variable_in_mixed_select_projection_fails_before_backend(): void { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( 'SELECT 1 AS n, @@definitely_unsupported_variable AS unsupported_value' ); + $this->fail( 'Expected unsupported MySQL system variable to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL system variable.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 0, $connection->get_query_count() ); + } + } + + /** + * Tests unsupported MySQL variable SELECT alias forms fail before backend execution. + */ + public function test_unsupported_mysql_variable_select_aliases_do_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'SELECT @@sql_mode AS', + 'SELECT @@sql_mode AS 1', + 'SELECT @@sql_mode 1', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL variable SELECT statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL variable SELECT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests WP-CLI/dump system-variable probes are selected from emulated state. + */ + public function test_wp_cli_dump_system_variable_probes_are_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + 'SELECT @@GLOBAL.gtid_purged, @@GLOBAL.log_bin, @@GLOBAL.log_bin_trust_function_creators, @@SESSION.max_allowed_packet, @@lower_case_table_names, @@hostname, @@protocol_version' + ); + + $this->assertSame( '', $rows[0]->{'@@GLOBAL.gtid_purged'} ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.log_bin'} ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.log_bin_trust_function_creators'} ); + $this->assertSame( '67108864', $rows[0]->{'@@SESSION.max_allowed_packet'} ); + $this->assertSame( '0', $rows[0]->{'@@lower_case_table_names'} ); + $this->assertSame( 'localhost', $rows[0]->{'@@hostname'} ); + $this->assertSame( '10', $rows[0]->{'@@protocol_version'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $version = $driver->query( "SHOW VARIABLES LIKE 'version'" ); + $this->assertCount( 1, $version ); + $this->assertSame( '8.0.38', $version[0]->Value ); + + $version_where = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'version'" ); + $this->assertCount( 1, $version_where ); + $this->assertSame( 'version', $version_where[0]->Variable_name ); + $this->assertSame( '8.0.38', $version_where[0]->Value ); + + $log_bin = $driver->query( "SHOW VARIABLES LIKE 'log_bin'" ); + $this->assertCount( 1, $log_bin ); + $this->assertSame( '0', $log_bin[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests WP-CLI/import SET toggles can be saved, changed, and reset. + */ + public function test_wp_cli_import_set_toggles_and_defaults_are_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_log_bin = @@sql_log_bin' ) ); + $this->assertSame( 0, $driver->query( 'SET @@sql_log_bin = 0' ) ); + $rows = $driver->query( 'SELECT @@sql_log_bin' ); + $this->assertSame( '0', $rows[0]->{'@@sql_log_bin'} ); + + $this->assertSame( 0, $driver->query( 'SET @@sql_log_bin = DEFAULT' ) ); + $rows = $driver->query( 'SELECT @@sql_log_bin' ); + $this->assertSame( '1', $rows[0]->{'@@sql_log_bin'} ); + + $this->assertSame( 0, $driver->query( 'SET @old_wait_timeout = @@wait_timeout' ) ); + $this->assertSame( 0, $driver->query( 'SET wait_timeout = 100' ) ); + $rows = $driver->query( 'SELECT @@wait_timeout' ); + $this->assertSame( '100', $rows[0]->{'@@wait_timeout'} ); + + $this->assertSame( 0, $driver->query( 'SET wait_timeout = @old_wait_timeout' ) ); + $rows = $driver->query( 'SELECT @@wait_timeout' ); + $this->assertSame( '28800', $rows[0]->{'@@wait_timeout'} ); + + $this->assertSame( 0, $driver->query( 'SET sql_quote_show_create = OFF, pseudo_replica_mode = ON' ) ); + $rows = $driver->query( 'SELECT @@sql_quote_show_create, @@pseudo_replica_mode' ); + $this->assertSame( '0', $rows[0]->{'@@sql_quote_show_create'} ); + $this->assertSame( '1', $rows[0]->{'@@pseudo_replica_mode'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests sql_warnings can be saved, changed, selected, and restored. + */ + public function test_sql_warnings_save_restore_flow_is_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_warnings = @@sql_warnings' ) ); + $this->assertSame( 0, $driver->query( 'SET sql_warnings = ON' ) ); + + $rows = $driver->query( 'SELECT @old_sql_warnings AS saved_warnings, @@sql_warnings warning_state' ); + $this->assertSame( '0', $rows[0]->saved_warnings ); + $this->assertSame( '1', $rows[0]->warning_state ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $show = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'sql_warnings'" ); + $this->assertCount( 1, $show ); + $this->assertSame( 'sql_warnings', $show[0]->Variable_name ); + $this->assertSame( '1', $show[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'SET @@sql_warnings = @old_sql_warnings' ) ); + $rows = $driver->query( 'SELECT @@sql_warnings warning_state' ); + $this->assertSame( '0', $rows[0]->warning_state ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests public SQL mode changes override earlier SQL SET state consistently. + */ + public function test_public_sql_mode_setter_overrides_sql_set_state_for_select_and_show_variables(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'" ) ); + $this->assertSame( 'NO_AUTO_VALUE_ON_ZERO', $driver->get_sql_mode() ); + + $driver->set_sql_mode( 'STRICT_ALL_TABLES' ); + + $this->assertSame( 'STRICT_ALL_TABLES', $driver->get_sql_mode() ); + + $rows = $driver->query( 'SELECT @@sql_mode' ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->{'@@sql_mode'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'sql_mode', $rows[0]->Variable_name ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests bare SHOW VARIABLES returns all emulated session variables. + */ + public function test_bare_show_variables_returns_all_known_session_variables_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $rows = $driver->query( 'SHOW VARIABLES' ); + + $variables = array(); + foreach ( $rows as $row ) { + $variables[ $row->Variable_name ] = $row->Value; + } + + $this->assertGreaterThan( 9, count( $variables ) ); + $this->assertSame( 'utf8', $variables['character_set_client'] ); + $this->assertSame( 'utf8', $variables['character_set_connection'] ); + $this->assertSame( 'utf8', $variables['character_set_results'] ); + $this->assertSame( 'utf8', $variables['character_set_database'] ); + $this->assertSame( 'utf8', $variables['character_set_server'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_connection'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_database'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_server'] ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $variables['sql_mode'] + ); + $this->assertSame( '1', $variables['autocommit'] ); + $this->assertSame( 'InnoDB', $variables['default_storage_engine'] ); + $this->assertSame( '1', $variables['foreign_key_checks'] ); + $this->assertSame( '67108864', $variables['max_allowed_packet'] ); + $this->assertSame( 'SYSTEM', $variables['time_zone'] ); + $this->assertSame( 'SHOW VARIABLES', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 2, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Variable_name', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Variable_name', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 64, + 'precision' => 0, + 'native_type' => 'string', + ), + array( + 'name' => 'Value', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Value', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + } + + /** + * Tests SHOW GLOBAL/SESSION VARIABLES read the requested emulated scope. + */ + public function test_scoped_show_variables_read_requested_scope(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $bare = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $bare ); + $this->assertSame( 'utf8', $bare[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $global ); + $this->assertSame( 'utf8mb4', $global[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session = $driver->query( "SHOW SESSION VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertEquals( $bare, $session ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests scoped SHOW VARIABLES LIKE and WHERE filters are emulated. + */ + public function test_scoped_show_variables_like_and_where_filters_work(): void { + $driver = $this->create_driver(); + + $global_like = $driver->query( "SHOW GLOBAL VARIABLES LIKE 'character_set_c%'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $global_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_like = $driver->query( "SHOW SESSION VARIABLES LIKE 'collation_%'" ); + $this->assertSame( + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $session_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global_where = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $global_where ); + $this->assertSame( 'character_set_client', $global_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4', $global_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $where_like = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $where_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_where = $driver->query( "SHOW SESSION VARIABLES WHERE Variable_name = 'collation_connection'" ); + $this->assertCount( 1, $session_where ); + $this->assertSame( 'collation_connection', $session_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4_unicode_ci', $session_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_exact = $driver->query( "SHOW VARIABLES WHERE Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $value_exact + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_like = $driver->query( "SHOW VARIABLES WHERE Value LIKE 'utf8mb4_%'" ); + $this->assertSame( + array( + 'default_collation_for_utf8mb4', + 'collation_connection', + 'collation_database', + 'collation_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $value_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $and_filter = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' AND Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $and_filter + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_not_equal = $driver->query( "SHOW VARIABLES WHERE Value <> 'utf8mb4'" ); + $this->assertNotSame( array(), $value_not_equal ); + foreach ( $value_not_equal as $row ) { + $this->assertNotSame( 'utf8mb4', $row->Value ); + } + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $or_filter = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' OR Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $or_filter + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES WHERE supports bounded numeric expressions on Value. + */ + public function test_show_variables_where_numeric_value_expressions_work(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SHOW VARIABLES WHERE (Value + 1) = 1025 AND Variable_name = 'group_concat_max_len'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'group_concat_max_len', $rows[0]->Variable_name ); + $this->assertSame( '1024', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Value + Variable_name > 1 AND Variable_name = 'group_concat_max_len'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'group_concat_max_len', $rows[0]->Variable_name ); + $this->assertSame( '1024', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES WHERE supports generic predicate expressions. + */ + public function test_show_variables_where_generic_predicate_expressions_work(): void { + $driver = $this->create_driver(); + + $selected_values = $driver->query( "SHOW VARIABLES WHERE Variable_name IN ('character_set_client', 'collation_connection')" ); + $this->assertSame( + array( + 'character_set_client', + 'collation_connection', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $selected_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $unknown_not_in_values = $driver->query( "SHOW VARIABLES WHERE Value NOT IN ('utf8mb4', NULL)" ); + $this->assertSame( array(), $unknown_not_in_values ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $truthy_variables = $driver->query( 'SHOW VARIABLES WHERE NOT 0' ); + $this->assertNotCount( 0, $truthy_variables ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $bounded_names = $driver->query( "SHOW VARIABLES WHERE Variable_name BETWEEN 'character_set_client' AND 'character_set_results'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $bounded_names + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW VARIABLES WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_variables_where_clause_does_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + "SHOW VARIABLES WHERE Unknown = 'utf8mb4'", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW VARIABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW VARIABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SET NAMES updates MySQL-compatible SHOW VARIABLES output. + */ + public function test_set_names_updates_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'collation_connection', $collation[0]->Variable_name ); + $this->assertSame( 'utf8_general_ci', $collation[0]->Value ); + + $charset = $driver->query( "SHOW VARIABLES LIKE 'character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8', $charset[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SET NAMES DEFAULT resets to the emulated MySQL defaults. + */ + public function test_set_names_default_resets_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ); + $this->assertSame( 0, $driver->query( 'SET NAMES DEFAULT' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8mb4', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8mb4_unicode_ci', $collation[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SET CHARSET aliases update MySQL-compatible SHOW VARIABLES output. + */ + public function test_set_charset_aliases_update_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET CHARSET utf8' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8_general_ci', $collation[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET CHARACTER SET utf8mb4' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8mb4', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8mb4_unicode_ci', $collation[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests supported MySQL session variables can be selected with MySQL aliases. + */ + public function test_session_system_variables_can_be_selected_with_mysql_aliases(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET character_set_client = 'latin1'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@character_set_client = 'utf8mb3'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb3', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@session.character_set_client = 'utf8mb4'" ) ); + $rows = $driver->query( 'SELECT @@session.character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@session.character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET default_storage_engine = InnoDB' ) ); + $rows = $driver->query( 'SELECT @@default_storage_engine' ); + $this->assertSame( 'InnoDB', $rows[0]->{'@@default_storage_engine'} ); + + $rows = $driver->query( 'SELECT @@SESSION.max_allowed_packet' ); + $this->assertSame( '67108864', $rows[0]->{'@@SESSION.max_allowed_packet'} ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='max_allowed_packet'" ); + $this->assertSame( '67108864', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( "SET SESSION time_zone = '+00:00'" ) ); + $rows = $driver->query( 'SELECT @@time_zone' ); + $this->assertSame( '+00:00', $rows[0]->{'@@time_zone'} ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='time_zone'" ); + $this->assertSame( '+00:00', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET GLOBAL foreign_key_checks = 0' ) ); + $rows = $driver->query( 'SELECT @@GLOBAL.foreign_key_checks' ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.foreign_key_checks'} ); + $rows = $driver->query( 'SELECT @@SESSION.foreign_key_checks' ); + $this->assertSame( '1', $rows[0]->{'@@SESSION.foreign_key_checks'} ); + $rows = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name='foreign_key_checks'" ); + $this->assertSame( '0', $rows[0]->Value ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='foreign_key_checks'" ); + $this->assertSame( '1', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( "SET GLOBAL sql_mode = 'ANSI_QUOTES'" ) ); + $rows = $driver->query( 'SELECT @@GLOBAL.sql_mode' ); + $this->assertSame( 'ANSI_QUOTES', $rows[0]->{'@@GLOBAL.sql_mode'} ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $driver->get_sql_mode() + ); + $rows = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertSame( 'ANSI_QUOTES', $rows[0]->Value ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertSame( $driver->get_sql_mode(), $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests keyword-valued MySQL session variables are emulated for PostgreSQL. + */ + public function test_keyword_session_system_variables_can_be_set_and_selected(): void { + $driver = $this->create_driver(); + + $cases = array( + 'SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci' => array( '@@default_collation_for_utf8mb4', 'utf8mb4_0900_ai_ci' ), + 'SET resultset_metadata = FULL' => array( '@@resultset_metadata', 'FULL' ), + 'SET session_track_gtids = OWN_GTID' => array( '@@session_track_gtids', 'OWN_GTID' ), + 'SET session_track_transaction_info = STATE' => array( '@@session_track_transaction_info', 'STATE' ), + 'SET transaction_isolation = SERIALIZABLE' => array( '@@transaction_isolation', 'SERIALIZABLE' ), + 'SET use_secondary_engine = FORCED' => array( '@@use_secondary_engine', 'FORCED' ), + ); + + foreach ( $cases as $query => $expected ) { + $this->assertSame( 0, $driver->query( $query ), $query ); + $rows = $driver->query( 'SELECT ' . $expected[0] ); + $this->assertSame( $expected[1], $rows[0]->{ $expected[0] }, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests ON/OFF system variables are emulated for PostgreSQL. + */ + public function test_on_off_session_system_variables_can_be_set_and_selected(): void { + $driver = $this->create_driver(); + + $cases = array( + 'SET autocommit = ON' => array( '@@autocommit', '1' ), + 'SET big_tables = OFF' => array( '@@big_tables', '0' ), + 'SET end_markers_in_json = ON' => array( '@@end_markers_in_json', '1' ), + 'SET explicit_defaults_for_timestamp = OFF' => array( '@@explicit_defaults_for_timestamp', '0' ), + 'SET keep_files_on_create = ON' => array( '@@keep_files_on_create', '1' ), + 'SET old_alter_table = OFF' => array( '@@old_alter_table', '0' ), + 'SET print_identified_with_as_hex = ON' => array( '@@print_identified_with_as_hex', '1' ), + 'SET require_row_format = OFF' => array( '@@require_row_format', '0' ), + 'SET select_into_disk_sync = ON' => array( '@@select_into_disk_sync', '1' ), + 'SET session_track_schema = ON' => array( '@@session_track_schema', '1' ), + 'SET session_track_state_change = OFF' => array( '@@session_track_state_change', '0' ), + 'SET show_create_table_skip_secondary_engine = ON' => array( '@@show_create_table_skip_secondary_engine', '1' ), + 'SET show_create_table_verbosity = OFF' => array( '@@show_create_table_verbosity', '0' ), + 'SET sql_auto_is_null = ON' => array( '@@sql_auto_is_null', '1' ), + 'SET sql_big_selects = OFF' => array( '@@sql_big_selects', '0' ), + 'SET sql_buffer_result = ON' => array( '@@sql_buffer_result', '1' ), + 'SET sql_safe_updates = OFF' => array( '@@sql_safe_updates', '0' ), + 'SET sql_warnings = ON' => array( '@@sql_warnings', '1' ), + 'SET transaction_read_only = OFF' => array( '@@transaction_read_only', '0' ), + ); + + foreach ( $cases as $query => $expected ) { + $this->assertSame( 0, $driver->query( $query ), $query ); + $rows = $driver->query( 'SELECT ' . $expected[0] ); + $this->assertSame( $expected[1], $rows[0]->{ $expected[0] }, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $this->assertSame( 0, $driver->query( "SET autocommit = 'on', big_tables = 'off'" ) ); + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests GROUP_CONCAT length SET state is emulated for PostgreSQL. + */ + public function test_group_concat_max_len_can_be_set_selected_and_restored(): void { + $driver = $this->create_driver(); + + $default = $driver->query( "SHOW VARIABLES LIKE 'group_concat_max_len'" ); + $this->assertCount( 1, $default ); + $this->assertSame( 'group_concat_max_len', $default[0]->Variable_name ); + $this->assertSame( '1024', $default[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'SET @old_group_concat_max_len = @@SESSION.group_concat_max_len' ) ); + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 1000000' ) ); + + $rows = $driver->query( 'SELECT @@SESSION.group_concat_max_len' ); + $this->assertSame( '1000000', $rows[0]->{'@@SESSION.group_concat_max_len'} ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'group_concat_max_len'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1000000', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET group_concat_max_len = @old_group_concat_max_len' ) ); + $rows = $driver->query( 'SELECT @@group_concat_max_len' ); + $this->assertSame( '1024', $rows[0]->{'@@group_concat_max_len'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests the default GROUP_CONCAT length limit is enforced for supported translations. + */ + public function test_group_concat_uses_default_group_concat_max_len(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => str_repeat( 'a', 600 ), + 2 => str_repeat( 'b', 600 ), + ) + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '') AS combined FROM group_concat_values" ); + + $this->assertSame( str_repeat( 'a', 600 ) . str_repeat( 'b', 424 ), $rows[0]->combined ); + $this->assertSame( 1024, strlen( $rows[0]->combined ) ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'STRING_AGG', $sql ); + $this->assertStringContainsString( ', 1024', $sql ); + } + + /** + * Tests small GROUP_CONCAT length limits truncate standard ORDER/SEPARATOR forms. + */ + public function test_group_concat_max_len_truncates_supported_group_concat_shapes(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 2 => 'two', + 1 => 'one', + 3 => 'three', + ) + ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values" ); + + $this->assertSame( 'one|t', $rows[0]->combined ); + $this->assertStringContainsString( ', 5', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(value ORDER BY id) AS combined FROM group_concat_values' ); + $this->assertSame( 'one,t', $rows[0]->combined ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(value) AS combined FROM group_concat_values WHERE id = 1' ); + $this->assertSame( 'one', $rows[0]->combined ); + } + + /** + * Tests SET group_concat_max_len = DEFAULT restores the default runtime behavior. + */ + public function test_group_concat_max_len_default_restore_updates_group_concat_output(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + ) + ); + + $query = "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values"; + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha', $rows[0]->combined ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = DEFAULT' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha|beta', $rows[0]->combined ); + + $variable = $driver->query( 'SELECT @@group_concat_max_len' ); + $this->assertSame( '1024', $variable[0]->{'@@group_concat_max_len'} ); + } + + /** + * Tests GROUP_CONCAT translations do not reuse stale group_concat_max_len state. + */ + public function test_group_concat_max_len_translation_does_not_use_stale_cached_state(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + ) + ); + + $query = "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values"; + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha', $rows[0]->combined ); + $this->assertStringContainsString( ', 5', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 9' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha|bet', $rows[0]->combined ); + $this->assertStringContainsString( ', 9', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests GROUP_CONCAT(expr, expr, ...) concatenates row expressions before aggregation. + */ + public function test_group_concat_multi_expression_rows_are_concatenated_before_aggregation(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 2 => 'two', + 1 => 'one', + 3 => 'three', + ) + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(id, ':', value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values" ); + + $this->assertSame( '1:one|2:two|3:three', $rows[0]->combined ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "CAST(id AS text) || CAST(':' AS text) || CAST(value AS text)", $sql ); + $this->assertStringContainsString( "STRING_AGG(CAST(CAST(id AS text) || CAST(':' AS text) || CAST(value AS text) AS text), CAST('|' AS text) ORDER BY id)", $sql ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests GROUP_CONCAT multi-expression rows preserve NULL skip semantics. + */ + public function test_group_concat_multi_expression_rows_skip_null_composites(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE group_concat_multi_values ( + id INTEGER PRIMARY KEY, + prefix TEXT NOT NULL, + suffix TEXT NULL + )' + ); + $driver->query( + "INSERT INTO group_concat_multi_values (id, prefix, suffix) VALUES + (1, 'a', '1'), + (2, 'b', NULL), + (3, 'c', '3')" + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(prefix, suffix ORDER BY id SEPARATOR ',') AS combined FROM group_concat_multi_values" ); + + $this->assertSame( 'a1,c3', $rows[0]->combined ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr) deduplicates values and keeps group_concat_max_len behavior. + */ + public function test_group_concat_distinct_default_separator_deduplicates_values(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + 3 => 'alpha', + 4 => 'beta', + ) + ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(DISTINCT value) AS combined FROM group_concat_values' ); + $parts = explode( ',', $rows[0]->combined ); + sort( $parts ); + + $this->assertSame( array( 'alpha', 'beta' ), $parts ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'GROUP_CONCAT(DISTINCT CAST(value AS text))', $sql ); + $this->assertStringContainsString( ', 1024', $sql ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr SEPARATOR literal) translates for PostgreSQL. + */ + public function test_group_concat_distinct_literal_separator_translates_for_postgresql(): void { + $driver = $this->create_backendless_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT GROUP_CONCAT(DISTINCT value SEPARATOR '|') AS combined FROM group_concat_values" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "STRING_AGG(DISTINCT CAST(value AS text), CAST('|' AS text))", $sql ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr ORDER BY expr) translates for PostgreSQL. + */ + public function test_group_concat_distinct_order_by_same_expression_translates_for_postgresql(): void { + $driver = $this->create_backendless_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT GROUP_CONCAT(DISTINCT value ORDER BY value DESC SEPARATOR '|') AS combined FROM group_concat_values" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( + "STRING_AGG(DISTINCT CAST(value AS text), CAST('|' AS text) ORDER BY CAST(value AS text) DESC)", + $sql + ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests unsupported GROUP_CONCAT() forms fail before backend execution. + */ + public function test_unsupported_group_concat_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT GROUP_CONCAT(DISTINCT id, value) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value ORDER BY id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value ORDER BY value, id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value SEPARATOR separator_value) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value ORDER id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value SEPARATOR) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value SEPARATOR "," SEPARATOR "|") AS combined FROM group_concat_values', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported GROUP_CONCAT() form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsafe GROUP_CONCAT length SET forms fail before reaching PDO. + */ + public function test_unsupported_group_concat_max_len_set_forms_fail_closed(): void { + $queries = array( + 'SET GLOBAL group_concat_max_len = 1000000', + 'SET GLOBAL group_concat_max_len = DEFAULT', + 'SET @@GLOBAL.group_concat_max_len = 1000000', + 'SET @@GLOBAL.group_concat_max_len = DEFAULT', + 'SET SESSION group_concat_max_len = OFF', + 'SET SESSION group_concat_max_len = 1 + 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests comma-separated boolean SET assignments are applied atomically. + */ + public function test_comma_separated_boolean_set_assignments_are_atomic(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET autocommit = ON, big_tables = OFF' ) ); + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + + try { + $driver->query( 'SET autocommit = OFF, unsupported_setting = 1' ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + } + + /** + * Tests user variables can be set, incremented, selected, and used for restore. + */ + public function test_user_variables_can_be_set_incremented_selected_and_used_for_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @my_var = 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '1', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @my_var = @my_var + 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '2', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @saved_cs_client = @@character_set_client' ) ); + $this->assertSame( 0, $driver->query( 'SET character_set_client = latin1' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET character_set_client = @saved_cs_client' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests conditional-comment SET wrappers work for supported backup and restore forms. + */ + public function test_conditional_comment_set_wrappers_handle_supported_backup_and_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( '/*!50503 SET NAMES utf8 */;' ) ); + $this->assertSame( 0, $driver->query( '/*!40101 SET @saved_cs_client = @@character_set_client */; ' ) ); + $this->assertSame( 0, $driver->query( '/*!50503 SET character_set_client = latin1 */;' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( '/*!40101 SET character_set_client = @saved_cs_client */;' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES LIKE honors MySQL wildcard patterns. + */ + public function test_show_variables_like_matches_wildcard_patterns(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SHOW VARIABLES LIKE 'character_set_%'" ); + + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $rows + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SET statements fail before reaching PDO. + */ + public function test_unsupported_set_statements_do_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'SET unsupported_setting = 1', + 'SET foreign_key_checks = 0, unsupported_setting = 1', + 'SET autocommit = 1 + 1', + 'SET @my_var = @my_var * 1', + "SET @@version = '8.0.39'", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( $query, $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + } + + /** + * Install a PostgreSQL-like options table and matching MySQL column metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_options_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->get_connection()->query( + 'CREATE TABLE wptests_options ( + option_id BIGSERIAL PRIMARY KEY, + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL DEFAULT \'\', + autoload TEXT NOT NULL DEFAULT \'yes\' + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "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 + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + "CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_date_gmt datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified_gmt timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (ID) + )" + ); + } + + /** + * Install a DML coercion table with temporal/YEAR MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_dml_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_values ( + id INTEGER PRIMARY KEY, + date_value TEXT, + datetime_value TEXT, + timestamp_value TEXT, + year_value TEXT + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_strict_values ( + id int(11) NOT NULL, + date_value date DEFAULT NULL, + datetime_value datetime DEFAULT NULL, + timestamp_value timestamp DEFAULT NULL, + year_value year DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a DML coercion table with integer-family MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_integer_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_ints ( + id INTEGER PRIMARY KEY, + int_value INTEGER, + tiny_unsigned INTEGER, + small_value INTEGER, + int_unsigned TEXT + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_strict_ints ( + id int(11) NOT NULL, + int_value int(11) DEFAULT NULL, + tiny_unsigned tinyint(3) unsigned DEFAULT NULL, + small_value smallint(6) DEFAULT NULL, + int_unsigned int(10) unsigned DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a DML coercion table with bounded text MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_text_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_texts ( + id INTEGER PRIMARY KEY, + varchar_value TEXT, + char_value TEXT, + tinytext_value TEXT + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_strict_texts ( + id int(11) NOT NULL, + varchar_value varchar(3) DEFAULT NULL, + char_value char(3) DEFAULT NULL, + tinytext_value tinytext, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a term relationships table with MySQL composite key metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + */ + private function install_term_relationships_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver, string $table_name ): void { + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `object_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `term_taxonomy_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `term_order` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`object_id`, `term_taxonomy_id`) + )', + $table_name + ) + ); + } + + /** + * Install an upsert table with ambiguous MySQL duplicate-key metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE ambiguous_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE ambiguous_upsert ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + } + + /** + * Install an upsert table with a MySQL prefix unique key. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_prefix_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE prefix_ambiguous ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->get_connection()->query( + 'CREATE UNIQUE INDEX prefix_ambiguous__slug ON prefix_ambiguous (SUBSTR(CAST(slug AS text), 1, 10))' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + '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 bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install an identity upsert table with a non-AUTO_INCREMENT unique key. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_identity_unique_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_identity_unique_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $this->install_mysql_schema_metadata_fixture( + $driver, + 'CREATE TABLE wptests_identity_unique_upsert ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + } + + /** + * Store MySQL-facing metadata using private driver catalog helpers. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $query MySQL CREATE TABLE query. + */ + private function install_mysql_schema_metadata_fixture( WP_PostgreSQL_Driver $driver, string $query ): void { + $store_metadata = Closure::bind( + function ( string $query ): void { + $tokens = $this->get_mysql_tokens( $query ); + $prefix = $this->get_mysql_create_table_prefix( $tokens ); + if ( null === $prefix ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata fixture.' ); + } + + if ( $prefix['temporary'] ) { + return; + } + + $position = $prefix['position']; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( + null === $table_reference + || ! $this->is_mysql_create_table_target_boundary( $tokens, $position, $prefix['statement_end'] ) + ) { + throw new InvalidArgumentException( 'Unsupported PostgreSQL catalog metadata fixture.' ); + } + + $this->sync_mysql_schema_catalog_side_effects_for_schema( + $this->get_postgresql_catalog_mysql_schema_metadata_or_fail( $query ), + $this->get_mysql_create_table_select_backend_schema( $table_reference, false ) + ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $store_metadata instanceof Closure ) { + throw new RuntimeException( 'Could not bind MySQL metadata test helper.' ); + } + + $store_metadata( $query ); + } + + /** + * Collect backend SQL generated by the last driver query. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $context Collection context. + * @param string[] $backend_sql Collected backend SQL. + */ + private function collect_last_postgresql_queries( + WP_PostgreSQL_Driver $driver, + string $context, + array &$backend_sql + ): void { + unset( $context ); + + foreach ( $driver->get_last_postgresql_queries() as $query ) { + $sql = (string) ( $query['sql'] ?? '' ); + $backend_sql[] = $sql; + } + } + + /** + * Assert SHOW OPEN TABLES uses the PostgreSQL relation and lock catalog path. + * + * @param array $query Captured query. + */ + private function assert_show_open_tables_catalog_query_shape( array $query ): void { + $this->assertStringContainsString( 'FROM pg_catalog.pg_class c', $query['sql'] ); + $this->assertStringContainsString( 'INNER JOIN pg_catalog.pg_namespace n', $query['sql'] ); + $this->assertStringContainsString( 'LEFT JOIN pg_catalog.pg_locks l', $query['sql'] ); + $this->assertStringContainsString( 'pg_catalog.pg_backend_pid()', $query['sql'] ); + $this->assertStringContainsString( 'c.relkind IN (\'r\', \'p\', \'v\', \'m\', \'f\')', $query['sql'] ); + $this->assertStringContainsString( 'ORDER BY c.relname', $query['sql'] ); + $this->assertSame( array( 'public' ), $query['params'] ); + } + + /** + * Assert SHOW TRIGGERS uses the PostgreSQL information_schema trigger path. + * + * @param array $query Captured query. + * @param string $display_schema Expected MySQL display schema. + */ + private function assert_show_triggers_catalog_query_shape( array $query, string $display_schema ): void { + $this->assertStringContainsString( 'FROM information_schema.triggers t', $query['sql'] ); + $this->assertStringContainsString( 't."TRIGGER_NAME" AS "Trigger"', $query['sql'] ); + $this->assertStringContainsString( 'WHERE t."TRIGGER_SCHEMA" = ?', $query['sql'] ); + $this->assertStringContainsString( 'ORDER BY t."TRIGGER_NAME"', $query['sql'] ); + $this->assertSame( array( $display_schema ), $query['params'] ); + } + + /** + * Assert SHOW FUNCTION/PROCEDURE STATUS uses the PostgreSQL information_schema routine path. + * + * @param array $query Captured query. + * @param string $routine_type Expected routine type. + */ + private function assert_show_routine_status_catalog_query_shape( array $query, string $routine_type ): void { + $this->assertStringContainsString( 'FROM information_schema.routines r', $query['sql'] ); + $this->assertStringContainsString( 'r."ROUTINE_NAME" AS "Name"', $query['sql'] ); + $this->assertStringContainsString( 'WHERE r."ROUTINE_TYPE" = ?', $query['sql'] ); + $this->assertStringContainsString( 'ORDER BY r."ROUTINE_SCHEMA", r."ROUTINE_NAME"', $query['sql'] ); + $this->assertSame( array( $routine_type ), $query['params'] ); + } + + /** + * Get a row value using case-insensitive column lookup. + * + * @param array|object $row Row object or associative array. + * @param string $column Column name. + * @return mixed Row value. + */ + private function get_row_value( $row, string $column ) { + $values = is_array( $row ) ? $row : get_object_vars( $row ); + foreach ( $values as $name => $value ) { + if ( 0 === strcasecmp( (string) $name, $column ) ) { + return $value; + } + } + + $this->fail( + sprintf( + 'Expected row column %s in row with columns: %s.', + $column, + implode( ', ', array_keys( $values ) ) + ) + ); + return null; + } + + /** + * Find the first row matching a case-insensitive column value. + * + * @param array $rows Rows. + * @param string $column Column name. + * @param string $expected_value Expected value. + * @return array|object Matching row. + */ + private function find_row_by_value( array $rows, string $column, string $expected_value ) { + foreach ( $rows as $row ) { + if ( $expected_value === (string) $this->get_row_value( $row, $column ) ) { + return $row; + } + } + + $this->fail( + sprintf( + 'Expected a row with %s = %s.', + $column, + $expected_value + ) + ); + return null; + } + + /** + * Find the first row matching a case-insensitive column value, if present. + * + * @param array $rows Rows. + * @param string $column Column name. + * @param string $expected_value Expected value. + * @return array|object|null Matching row, or null. + */ + private function find_optional_row_by_value( array $rows, string $column, string $expected_value ) { + foreach ( $rows as $row ) { + if ( $expected_value === (string) $this->get_row_value( $row, $column ) ) { + return $row; + } + } + + return null; + } + + /** + * Drop public PostgreSQL tables using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Table-name prefix before the separator underscore. + */ + private function drop_public_pgsql_tables_with_prefix( PDO $pdo, string $prefix ): void { + foreach ( $this->get_public_pgsql_tables_with_prefix( $pdo, $prefix ) as $table_name ) { + $pdo->exec( + 'DROP TABLE IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( (string) $table_name ) + . ' CASCADE' + ); + } + } + + /** + * Drop public PostgreSQL views using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix View-name prefix before the separator underscore. + */ + private function drop_public_pgsql_views_with_prefix( PDO $pdo, string $prefix ): void { + foreach ( $this->get_public_pgsql_views_with_prefix( $pdo, $prefix ) as $view_name ) { + $pdo->exec( + 'DROP VIEW IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( (string) $view_name ) + . ' CASCADE' + ); + } + } + + /** + * Drop public PostgreSQL functions using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Function-name prefix before the separator underscore. + */ + private function drop_public_pgsql_functions_with_prefix( PDO $pdo, string $prefix ): void { + foreach ( $this->get_public_pgsql_functions_with_prefix( $pdo, $prefix ) as $function ) { + $pdo->exec( + 'DROP FUNCTION IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( (string) $function['name'] ) + . '(' + . (string) $function['arguments'] + . ') CASCADE' + ); + } + } + + /** + * Drop public PostgreSQL procedures using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Procedure-name prefix before the separator underscore. + */ + private function drop_public_pgsql_procedures_with_prefix( PDO $pdo, string $prefix ): void { + foreach ( $this->get_public_pgsql_procedures_with_prefix( $pdo, $prefix ) as $procedure ) { + $pdo->exec( + 'DROP PROCEDURE IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( (string) $procedure['name'] ) + . '(' + . (string) $procedure['arguments'] + . ')' + ); + } + } + + /** + * Drop PostgreSQL schemas using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Schema-name prefix before the separator underscore. + */ + private function drop_pgsql_schemas_with_prefix( PDO $pdo, string $prefix ): void { + foreach ( $this->get_pgsql_schemas_with_prefix( $pdo, $prefix ) as $schema_name ) { + $pdo->exec( + 'DROP SCHEMA IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( (string) $schema_name ) + . ' CASCADE' + ); + } + } + + /** + * Drop PostgreSQL roles using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Role-name prefix before the separator underscore. + */ + private function drop_pgsql_roles_with_prefix( PDO $pdo, string $prefix ): void { + $current_user = (string) $pdo->query( 'SELECT current_user' )->fetchColumn(); + $current_sql = WP_PostgreSQL_Connection::quote_identifier_value( $current_user ); + + foreach ( $this->get_pgsql_roles_with_prefix( $pdo, $prefix ) as $role_name ) { + $role_sql = WP_PostgreSQL_Connection::quote_identifier_value( (string) $role_name ); + + $pdo->exec( 'REVOKE ' . $role_sql . ' FROM ' . $current_sql ); + $pdo->exec( 'DROP OWNED BY ' . $role_sql ); + $pdo->exec( 'DROP ROLE IF EXISTS ' . $role_sql ); + } + } + + /** + * Get PostgreSQL schemas using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Schema-name prefix before the separator underscore. + * @return string[] Schema names. + */ + private function get_pgsql_schemas_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT schema_name + FROM information_schema.schemata + WHERE schema_name LIKE ? ESCAPE '\\' + ORDER BY schema_name" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get PostgreSQL roles using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Role-name prefix before the separator underscore. + * @return string[] Role names. + */ + private function get_pgsql_roles_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT rolname + FROM pg_catalog.pg_roles + WHERE rolname LIKE ? ESCAPE '\\' + ORDER BY rolname" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get PostgreSQL type rows by schema and type name. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $schema Schema name. + * @param string[] $type_names Type names. + * @return array> Type rows keyed by type name. + */ + private function get_pgsql_type_rows_by_name( PDO $pdo, string $schema, array $type_names ): array { + if ( array() === $type_names ) { + return array(); + } + + $placeholders = implode( ', ', array_fill( 0, count( $type_names ), '?' ) ); + $stmt = $pdo->prepare( + "SELECT + t.typname, + t.typtype, + pg_catalog.format_type(t.typbasetype, t.typtypmod) AS base_type, + pg_catalog.obj_description(t.oid, 'pg_type') AS type_comment + FROM pg_catalog.pg_type t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.typnamespace + WHERE n.nspname = ? + AND t.typname IN ({$placeholders}) + ORDER BY t.typname" + ); + $stmt->execute( array_merge( array( $schema ), $type_names ) ); + + $rows = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $rows[ (string) $row['typname'] ] = $row; + } + return $rows; + } + + /** + * Get PostgreSQL enum labels by schema and enum type name. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $schema Schema name. + * @param string $type_name Enum type name. + * @return string[] Enum labels. + */ + private function get_pgsql_enum_labels( PDO $pdo, string $schema, string $type_name ): array { + $stmt = $pdo->prepare( + 'SELECT e.enumlabel + FROM pg_catalog.pg_enum e + INNER JOIN pg_catalog.pg_type t + ON t.oid = e.enumtypid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.typnamespace + WHERE n.nspname = ? + AND t.typname = ? + ORDER BY e.enumsortorder' + ); + $stmt->execute( array( $schema, $type_name ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get PostgreSQL table references in schemas using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Schema-name prefix before the separator underscore. + * @return string[] Schema-qualified table names. + */ + private function get_pgsql_tables_in_schemas_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT table_schema || '.' || table_name AS table_reference + FROM information_schema.tables + WHERE table_schema LIKE ? ESCAPE '\\' + ORDER BY table_schema, table_name" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get public PostgreSQL tables using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Table-name prefix before the separator underscore. + * @return string[] Table names. + */ + private function get_public_pgsql_tables_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name LIKE ? ESCAPE '\\' + ORDER BY table_name" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get public PostgreSQL views using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix View-name prefix before the separator underscore. + * @return string[] View names. + */ + private function get_public_pgsql_views_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT table_name + FROM information_schema.views + WHERE table_schema = 'public' + AND table_name LIKE ? ESCAPE '\\' + ORDER BY table_name" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Get physical PostgreSQL index names for a public table. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $table_name Public table name. + * @return string[] Index names. + */ + private function get_public_pgsql_index_names_for_table( PDO $pdo, string $table_name ): array { + $stmt = $pdo->prepare( + "SELECT idx.relname + FROM pg_catalog.pg_index i + INNER JOIN pg_catalog.pg_class t + ON t.oid = i.indrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + WHERE n.nspname = 'public' + AND t.relname = ? + ORDER BY idx.relname" + ); + $stmt->execute( array( $table_name ) ); + + return $stmt->fetchAll( PDO::FETCH_COLUMN ); + } + + /** + * Check whether a public PostgreSQL trigger exists on a table. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $table_name Public table name. + * @param string $trigger_name Trigger name. + * @return bool Whether the trigger exists. + */ + private function public_pgsql_trigger_exists( PDO $pdo, string $table_name, string $trigger_name ): bool { + $stmt = $pdo->prepare( + "SELECT 1 + FROM pg_catalog.pg_trigger tr + INNER JOIN pg_catalog.pg_class t + ON t.oid = tr.tgrelid + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = ? + AND tr.tgname = ? + AND NOT tr.tgisinternal + LIMIT 1" + ); + $stmt->execute( array( $table_name, $trigger_name ) ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether a public PostgreSQL function exists. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $function_name Function name. + * @return bool Whether the function exists. + */ + private function public_pgsql_function_exists( PDO $pdo, string $function_name ): bool { + $stmt = $pdo->prepare( + "SELECT 1 + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind = 'f' + AND p.proname = ? + LIMIT 1" + ); + $stmt->execute( array( $function_name ) ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Drop a public PostgreSQL no-argument function if it exists. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $function_name Function name. + */ + private function drop_public_pgsql_function( PDO $pdo, string $function_name ): void { + $pdo->exec( + 'DROP FUNCTION IF EXISTS ' + . WP_PostgreSQL_Connection::quote_identifier_value( 'public' ) + . '.' + . WP_PostgreSQL_Connection::quote_identifier_value( $function_name ) + . '() CASCADE' + ); + } + + /** + * Get public PostgreSQL functions using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Function-name prefix before the separator underscore. + * @return array Function rows. + */ + private function get_public_pgsql_functions_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT + p.proname AS name, + pg_catalog.pg_get_function_identity_arguments(p.oid) AS arguments + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind = 'f' + AND p.proname LIKE ? ESCAPE '\\' + ORDER BY p.proname, arguments" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get public PostgreSQL procedures using the given task prefix. + * + * @param PDO $pdo PostgreSQL PDO connection. + * @param string $prefix Procedure-name prefix before the separator underscore. + * @return array Procedure rows. + */ + private function get_public_pgsql_procedures_with_prefix( PDO $pdo, string $prefix ): array { + $stmt = $pdo->prepare( + "SELECT + p.proname AS name, + pg_catalog.pg_get_function_identity_arguments(p.oid) AS arguments + FROM pg_catalog.pg_proc p + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind = 'p' + AND p.proname LIKE ? ESCAPE '\\' + ORDER BY p.proname, arguments" + ); + $stmt->execute( array( $prefix . '\\_%' ) ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Creates a PostgreSQL driver backed by the real PostgreSQL test connection. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Driver { + return $this->create_real_pgsql_driver( $db_name, true ); + } + + /** + * Creates a PostgreSQL driver backed by the real PostgreSQL test connection. + * + * @param string $db_name MySQL-facing database name. + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_real_pgsql_driver( string $db_name = 'wptests', bool $use_schema_as_current_database = false ): WP_PostgreSQL_Driver { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run this real PostgreSQL driver test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $schema = 'wp_pg_test_' . strtolower( bin2hex( random_bytes( 8 ) ) ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $this->real_pgsql_test_schemas[] = array( + 'pdo' => $pdo, + 'schema' => $schema, + ); + + $pdo->exec( 'SET search_path TO ' . $schema_sql . ', public' ); + + $driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ), + $db_name + ); + if ( $use_schema_as_current_database ) { + $db_name_property = new ReflectionProperty( WP_PostgreSQL_Driver::class, 'db_name' ); + $db_name_property->setValue( $driver, $schema ); + } + + return $driver; + } + + /** + * Creates a PostgreSQL driver for private SQL-shape helpers without opening a backend. + * + * @param string $db_name MySQL-facing database name. + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_backendless_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Driver { + $driver_reflection = new ReflectionClass( WP_PostgreSQL_Driver::class ); + $driver = $driver_reflection->newInstanceWithoutConstructor(); + $connection = new class() extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + + public function quote_identifier( string $unquoted_identifier ): string { + return self::quote_identifier_value( $unquoted_identifier ); + } + }; + + foreach ( + array( + 'connection' => $connection, + 'main_db_name' => $db_name, + 'db_name' => $db_name, + 'mysql_version' => 80038, + 'mysql_has_active_temporary_tables' => false, + ) as $property_name => $property_value + ) { + $property = new ReflectionProperty( WP_PostgreSQL_Driver::class, $property_name ); + $property->setValue( $driver, $property_value ); + } + + $driver->client_info = 'PostgreSQL'; + return $driver; + } + + /** + * Creates a PostgreSQL driver whose connection records full-table column catalog reads. + * + * @return array{0:WP_PostgreSQL_Driver,1:WP_PostgreSQL_Connection,2:string} Driver, connection, and backend schema. + */ + private function create_create_metadata_preseed_capture_driver(): array { + $dsn = getenv( 'PGSQL_TEST_DSN' ); + if ( false === $dsn || '' === $dsn ) { + $this->markTestSkipped( 'Set PGSQL_TEST_DSN to run this real PostgreSQL metadata preseed test.' ); + } + + $user = getenv( 'PGSQL_TEST_USER' ); + $password = getenv( 'PGSQL_TEST_PASSWORD' ); + $pdo = new PDO( + $dsn, + false === $user ? null : $user, + false === $password ? null : $password + ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + $this->assertSame( + 'pgsql', + $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ), + 'PGSQL_TEST_DSN must use the pgsql PDO driver.' + ); + + $schema = 'wp_pg_test_' . strtolower( bin2hex( random_bytes( 8 ) ) ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $this->real_pgsql_test_schemas[] = array( + 'pdo' => $pdo, + 'schema' => $schema, + ); + + $pdo->exec( 'SET search_path TO ' . $schema_sql . ', public' ); + + $connection = new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { + /** + * Captured backend queries. + * + * @var array[] + */ + private $queries = array(); + + /** + * Captured full-table column catalog metadata reads. + * + * @var array[] + */ + private $column_catalog_queries = array(); + + /** + * Execute a PostgreSQL query and record full-table column catalog reads. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + if ( + false !== strpos( $sql, 'FROM information_schema.columns c' ) + && false !== strpos( $sql, 'pg_catalog.col_description(pc.oid, pa.attnum)' ) + ) { + $this->column_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get captured backend queries. + * + * @return array[] Backend queries. + */ + public function get_queries(): array { + return $this->queries; + } + + /** + * Clear captured backend queries. + */ + public function clear_queries(): void { + $this->queries = array(); + } + + /** + * Get captured full-table column catalog metadata reads. + * + * @return array[] Catalog queries. + */ + public function get_column_catalog_queries(): array { + return $this->column_catalog_queries; + } + + /** + * Clear captured column catalog metadata reads. + */ + public function clear_column_catalog_queries(): void { + $this->column_catalog_queries = array(); + } + }; + + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $db_name_property = new ReflectionProperty( WP_PostgreSQL_Driver::class, 'db_name' ); + $db_name_property->setValue( $driver, $schema ); + + return array( $driver, $connection, $schema ); + } + + /** + * Get CREATE TABLE SQL for metadata preseed row-equivalence coverage. + * + * @param string $table Table name. + * @return string MySQL CREATE TABLE SQL. + */ + private function get_create_metadata_preseed_fixture_sql( string $table ): string { + return "CREATE TABLE `{$table}` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(191) CHARACTER SET big5 NOT NULL DEFAULT '' COMMENT 'Title note', + `body` longtext, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `touched_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `status` enum('draft','published') NOT NULL DEFAULT 'draft', + `flags` set('flag-a','flag-b'), + `price` decimal(10,2) unsigned NOT NULL DEFAULT '0.00', + PRIMARY KEY (`id`), + KEY `title` (`title`) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Preseed table'"; + } + + /** + * Read real catalog column metadata rows through the driver's private reader. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $schema Backend schema. + * @param string $table Table name. + * @param string|null $column_name Optional column name. + * @param bool $case_sensitive_column Whether the optional column lookup is case-sensitive. + * @return array[] Metadata rows. + */ + private function read_mysql_catalog_column_metadata_rows( + WP_PostgreSQL_Driver $driver, + string $schema, + string $table, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + $read_rows = Closure::bind( + function ( + string $schema, + string $table, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + return $this->read_mysql_table_catalog_column_metadata_rows( + $schema, + $table, + $column_name, + $case_sensitive_column + ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $read_rows instanceof Closure ) { + throw new RuntimeException( 'Could not bind MySQL metadata catalog reader.' ); + } + + return $read_rows( $schema, $table, $column_name, $case_sensitive_column ); + } + + /** + * Read catalog rows with the pre-factoring projection for row-equivalence coverage. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $schema Backend schema. + * @param string $table Table name. + * @param string|null $column_name Optional column name. + * @param bool $case_sensitive_column Whether the optional column lookup is case-sensitive. + * @return array[] Metadata rows. + */ + private function read_legacy_mysql_catalog_column_metadata_rows( + WP_PostgreSQL_Driver $driver, + string $schema, + string $table, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + $read_rows = Closure::bind( + function ( + string $schema, + string $table, + ?string $column_name = null, + bool $case_sensitive_column = false + ): array { + $column_comment_sql = 'pg_catalog.col_description(pc.oid, pa.attnum)'; + $column_type = $this->get_direct_information_schema_catalog_column_type_expression( + 'c', + $this->get_postgresql_identity_sequence_comment_sql( 'c' ), + $column_comment_sql + ); + $collation = $this->get_direct_information_schema_collation_expression( + $column_type, + 'c.collation_name', + $column_comment_sql, + $this->connection->quote( self::DEFAULT_MYSQL_COLLATION ) + ); + $projection_sql = sprintf( + 'c.column_name, + c.ordinal_position, + %1$s AS column_type, + %2$s AS collation_name, + c.is_nullable, + %3$s AS column_default, + %4$s AS extra', + $column_type, + $collation, + $this->get_direct_information_schema_column_default_expression( 'c', $column_comment_sql ), + $this->get_direct_information_schema_column_extra_expression( 'c', true, $column_comment_sql ) + ); + $column_filter_sql = ''; + if ( null !== $column_name ) { + $column_filter_sql = $case_sensitive_column + ? "\n\t\t\t\t\tAND c.column_name = ?" + : "\n\t\t\t\t\tAND LOWER(c.column_name) = LOWER(?)"; + } + $sql = sprintf( + 'SELECT %1$s + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_namespace pn + ON pn.nspname = c.table_schema + LEFT JOIN pg_catalog.pg_class pc + ON pc.relnamespace = pn.oid + AND pc.relname = c.table_name + AND pc.relkind IN (\'r\', \'p\', \'v\', \'m\') + LEFT JOIN pg_catalog.pg_attribute pa + ON pa.attrelid = pc.oid + AND pa.attname = c.column_name + AND pa.attnum > 0 + WHERE c.table_schema = ? + AND c.table_name = ?%2$s + ORDER BY c.ordinal_position%3$s', + $projection_sql, + $column_filter_sql, + null !== $column_name ? "\n\t\t\t\tLIMIT 2" : '' + ); + $params = array( $schema, $table ); + if ( null !== $column_name ) { + $params[] = $column_name; + } + + $stmt = $this->connection->query( $sql, $params ); + return $this->normalize_mysql_table_catalog_column_metadata_rows( $stmt->fetchAll( PDO::FETCH_ASSOC ) ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $read_rows instanceof Closure ) { + throw new RuntimeException( 'Could not bind legacy MySQL metadata catalog reader.' ); + } + + return $read_rows( $schema, $table, $column_name, $case_sensitive_column ); + } + + /** + * Get cached catalog column metadata rows through the driver's private cache helper. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $schema Backend schema. + * @param string $table Table name. + * @return array[] Metadata rows. + */ + private function get_cached_mysql_catalog_column_metadata_rows( WP_PostgreSQL_Driver $driver, string $schema, string $table ): array { + $get_rows = Closure::bind( + function ( string $schema, string $table ): array { + return $this->get_cached_mysql_table_catalog_column_metadata_rows( $schema, $table ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $get_rows instanceof Closure ) { + throw new RuntimeException( 'Could not bind MySQL metadata catalog cache reader.' ); + } + + return $get_rows( $schema, $table ); + } + + /** + * Clear driver metadata caches through the driver's private helper. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function clear_driver_mysql_metadata_caches( WP_PostgreSQL_Driver $driver ): void { + $clear_caches = Closure::bind( + function (): void { + $this->clear_mysql_metadata_caches(); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $clear_caches instanceof Closure ) { + throw new RuntimeException( 'Could not bind MySQL metadata cache clearer.' ); + } + + $clear_caches(); + } + + /** + * Get the backend temporary schema for a table through the driver's private helper. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table Table name. + * @return string Temporary schema name. + */ + private function get_temporary_metadata_schema_for_table( WP_PostgreSQL_Driver $driver, string $table ): string { + $get_schema = Closure::bind( + function ( string $table ): string { + return $this->get_temporary_schema_for_metadata_table( $table ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + if ( ! $get_schema instanceof Closure ) { + throw new RuntimeException( 'Could not bind MySQL temporary metadata schema reader.' ); + } + + return $get_schema( $table ); + } + + /** + * Index metadata rows by column name. + * + * @param array[] $rows Metadata rows. + * @return array Metadata rows keyed by column name. + */ + private function index_metadata_rows_by_column_name( array $rows ): array { + $indexed = array(); + foreach ( $rows as $row ) { + $indexed[ (string) $row['column_name'] ] = $row; + } + return $indexed; + } + + /** + * Creates a small table for GROUP_CONCAT behavior tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param array $rows Values keyed by integer ID. + */ + private function create_group_concat_values_table( WP_PostgreSQL_Driver $driver, array $rows ): void { + $driver->query( 'CREATE TABLE group_concat_values (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + foreach ( $rows as $id => $value ) { + $driver->query( + sprintf( + 'INSERT INTO group_concat_values (id, value) VALUES (%d, %s)', + $id, + $driver->get_connection()->quote( $value ) + ) + ); + } + } + + /** + * Creates a PostgreSQL driver with tables used by index hint translation tests. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_index_hint_tables(): WP_PostgreSQL_Driver { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER, value TEXT)' ); + $driver->query( 'CREATE TABLE j (t_id INTEGER, value TEXT)' ); + + return $driver; + } + + /** + * Get the last single PostgreSQL SQL statement executed by a driver. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @return string Last PostgreSQL SQL. + */ + private function get_last_single_postgresql_sql( WP_PostgreSQL_Driver $driver ): string { + $queries = $driver->get_last_postgresql_queries(); + + $this->assertCount( 1, $queries ); + return $queries[0]['sql']; + } + + /** + * Remove randomized real PostgreSQL test schema qualifiers from SQL-shape assertions. + * + * @param string $sql SQL statement. + * @return string SQL statement without randomized test schema qualifiers. + */ + private function remove_real_pgsql_test_schema_qualifiers( string $sql ): string { + return (string) preg_replace( '/"wp_pg_test_[0-9a-f]{16}"\\./', '', $sql ); + } + + /** + * Get logged PostgreSQL SQL statements without randomized test schema qualifiers. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @return string[] Normalized PostgreSQL SQL statements. + */ + private function get_normalized_last_postgresql_sql_statements( WP_PostgreSQL_Driver $driver ): array { + return array_map( + array( $this, 'remove_real_pgsql_test_schema_qualifiers' ), + array_column( $driver->get_last_postgresql_queries(), 'sql' ) + ); + } + + /** + * Assert the last logged PostgreSQL SQL statements without parameters. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string[] $sql Expected SQL statements. + */ + private function assert_last_postgresql_sql_statements( WP_PostgreSQL_Driver $driver, array $sql ): void { + $normalized_sql = $this->get_normalized_last_postgresql_sql_statements( $driver ); + $actual = array_map( + static function ( string $statement ): array { + return array( + 'sql' => $statement, + 'params' => array(), + ); + }, + $normalized_sql + ); + + $this->assertSame( + array_map( + static function ( string $statement ): array { + return array( + 'sql' => $statement, + 'params' => array(), + ); + }, + $sql + ), + $actual + ); + } + + /** + * Assert the last query used the materialized INSERT ... SELECT upsert flow. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Target table name. + * @return string[] Logged SQL statements. + */ + private function assert_last_upsert_select_materialized_sql( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $queries = $driver->get_last_postgresql_queries(); + $this->assertGreaterThanOrEqual( 4, count( $queries ) ); + + $sql = array_map( + array( $this, 'remove_real_pgsql_test_schema_qualifiers' ), + array_column( $queries, 'sql' ) + ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertSame( $sql[0], $sql[3] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertStringStartsWith( + sprintf( + 'INSERT INTO "%s" ', + $table_name + ), + $sql[2] + ); + $this->assertStringContainsString( ' FROM "__wp_pg_upsert_select_', $sql[2] ); + $this->assertStringContainsString( ' WHERE 1 = 1 ON CONFLICT ', $sql[2] ); + + return $sql; + } + + /** + * Assert the last query used the materialized REPLACE ... SELECT flow. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Target table name. + * @return string[] Logged SQL statements. + */ + private function assert_last_replace_select_materialized_sql( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 5, $queries ); + + $sql = array_map( + array( $this, 'remove_real_pgsql_test_schema_qualifiers' ), + array_column( $queries, 'sql' ) + ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertSame( $sql[0], $sql[4] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertStringStartsWith( + 'DELETE FROM "' . $table_name . '" AS "__wp_pg_replace_target" WHERE EXISTS (SELECT 1 FROM "__wp_pg_replace_select_', + $sql[2] + ); + $this->assertStringStartsWith( + 'INSERT INTO "' . $table_name . '" ', + $sql[3] + ); + + return $sql; + } + + /** + * Get the first logged PostgreSQL SQL statement containing a string. + * + * @param array[] $queries Logged PostgreSQL query records. + * @param string $needle SQL fragment to find. + * @return string Matching SQL statement. + */ + private function get_logged_postgresql_sql_containing( array $queries, string $needle ): string { + foreach ( $queries as $query ) { + if ( false !== strpos( $query['sql'], $needle ) ) { + return $query['sql']; + } + } + + $this->fail( 'Expected PostgreSQL SQL containing: ' . $needle ); + } + + /** + * Count logged identity sequence repair metadata probes. + * + * @param string[] $logged_sql Logged SQL statements. + * @return int Matching query count. + */ + private function count_identity_sequence_repair_metadata_queries( array $logged_sql ): int { + $count = 0; + foreach ( $logged_sql as $sql ) { + if ( + false !== strpos( $sql, 'pg_get_serial_sequence(format' ) + && false !== strpos( $sql, 'FROM information_schema.columns c' ) + && false !== strpos( $sql, 'seq.relname AS sequence_name' ) + ) { + ++$count; + } + } + return $count; + } + + /** + * Count logged DML identity eligibility metadata probes. + * + * @param string[] $logged_sql Logged SQL statements. + * @return int Matching query count. + */ + private function count_dml_identity_eligibility_metadata_queries( array $logged_sql ): int { + $count = 0; + foreach ( $logged_sql as $sql ) { + if ( + false !== strpos( $sql, 'pg_catalog.col_description(pc.oid, pa.attnum)' ) + && false !== strpos( $sql, 'FROM information_schema.columns c' ) + && false !== strpos( $sql, 'pc.relkind IN (\'r\', \'p\', \'v\', \'m\')' ) + ) { + ++$count; + } + } + return $count; + } + + /** + * Get logged guarded identity sequence repair queries. + * + * @param array[] $queries Logged PostgreSQL query records. + * @return array[] Matching query records. + */ + private function get_identity_sequence_repair_queries( array $queries ): array { + return array_values( + array_filter( + $queries, + static function ( array $query ): bool { + return false !== strpos( + (string) $query['sql'], + 'SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true)' + ); + } + ) + ); + } + + /** + * 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 ); + } + } + + /** + * 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, + ), + ); + } + + /** + * Get DML identity metadata rows for the identity upsert fixture. + * + * @return array[] Fixture metadata rows. + */ + private function get_dml_identity_upsert_metadata_fixture(): array { + return array_merge( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ), + array( + array( + 'table_schema' => 'public', + 'table_name' => 'wptests_identity_upsert', + 'column_name' => 'value', + 'ordinal_position' => 2, + 'data_type' => 'text', + 'is_identity' => 'NO', + 'column_default' => null, + 'mysql_column_type' => 'longtext', + 'mysql_extra' => '', + 'sequence_schema' => null, + 'sequence_name' => null, + ), + ) + ); + } + + /** + * 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'] ); + } + + /** + * Get external SQLite UDF registry function names. + * + * @return string[] Function names. + */ + private function get_external_sqlite_udf_registry_functions(): array { + $reflection = new ReflectionClass( WP_SQLite_PDO_User_Defined_Functions::class ); + $property = $reflection->getProperty( 'functions' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + + $functions = array_keys( $property->getValue( $reflection->newInstanceWithoutConstructor() ) ); + $functions = array_values( + array_diff( + $functions, + array( + 'throw', + '_helper_like_to_glob_pattern', + ) + ) + ); + + sort( $functions ); + + return $functions; + } + + /** + * Call a private driver method. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param array $args Method arguments. + * @return mixed Private method return value. + */ + private function call_driver_private_method( WP_PostgreSQL_Driver $driver, string $method_name, array $args = array() ) { + $caller = Closure::bind( + function ( string $bound_method_name, array $bound_args ) { + return $this->$bound_method_name( ...$bound_args ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $caller( $method_name, $args ); + } + + /** + * Translate a query by calling a private driver translator. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_driver_query_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?string { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?string { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } + + /** + * Translate a query to structured query data by calling a private method. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when unsupported. + */ + private function translate_driver_query_data_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?array { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?array { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } + + /** + * Get a private driver property for cache-focused assertions. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $property_name Private property name. + * @return mixed Private property value. + */ + private function get_driver_private_property( WP_PostgreSQL_Driver $driver, string $property_name ) { + $property_reader = Closure::bind( + function ( string $bound_property_name ) { + return $this->$bound_property_name; + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $property_reader( $property_name ); + } + + /** + * Get expected PostgreSQL SQL for MySQL-compatible integer casts. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_integer_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(%1$s, \'^[[:space:]]*[+-]?[0-9]+\'), \'0\') AS bigint) END', + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL-compatible decimal text coercion. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_numeric_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $substring_sql = array(); + $numeric_patterns = array( + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[.][0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*', + '^[[:space:]]*[+-]?[.][0-9]+', + '^[[:space:]]*[+-]?[0-9]+', + ); + + foreach ( $numeric_patterns as $pattern ) { + $substring_sql[] = sprintf( + 'SUBSTRING(%1$s, \'%2$s\')', + $expression_text_sql, + $pattern + ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(%2$s, \'0\') AS numeric) END', + $expression_text_sql, + implode( ', ', $substring_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_ADD/DATE_SUB arithmetic. + * + * @param string $operator PostgreSQL interval operator. + * @param string $expression_sql PostgreSQL date/time expression SQL. + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit PostgreSQL interval unit. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_date_arithmetic_sql( string $operator, string $expression_sql, string $value_sql, string $unit ): string { + return $this->get_expected_date_arithmetic_with_interval_sql( + $operator, + $expression_sql, + $this->get_expected_mysql_interval_sql( $value_sql, $unit ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_ADD/DATE_SUB arithmetic with an interval SQL expression. + * + * @param string $operator PostgreSQL interval operator. + * @param string $expression_sql PostgreSQL date/time expression SQL. + * @param string $interval_sql PostgreSQL interval SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_date_arithmetic_with_interval_sql( string $operator, string $expression_sql, string $interval_sql ): string { + return sprintf( + '(%1$s %2$s %3$s)', + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ), + $operator, + $interval_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL-compatible interval expression. + * + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit Normalized interval unit. + * @return string PostgreSQL interval SQL. + */ + private function get_expected_mysql_interval_sql( string $value_sql, string $unit ): string { + $interval_unit = '3 months' === $unit ? $unit : '1 ' . $unit; + + return sprintf( + "(%1\$s * INTERVAL '%2\$s')", + $this->get_expected_mysql_interval_value_sql( $value_sql, $unit ), + $interval_unit + ); + } + + /** + * Get expected PostgreSQL SQL for parsed MySQL composite interval components. + * + * @param array $components Parsed interval component value/unit pairs. + * @return string PostgreSQL interval SQL. + */ + private function get_expected_mysql_composite_interval_sql( array $components ): string { + $parts = array(); + foreach ( $components as $component ) { + $parts[] = sprintf( + "(CAST('%1\$s' AS double precision) * INTERVAL '1 %2\$s')", + $component[0], + $component[1] + ); + } + + return '(' . implode( ' + ', $parts ) . ')'; + } + + /** + * Get expected PostgreSQL SQL for a MySQL-compatible interval value. + * + * @param string $value_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_interval_value_sql( string $value_sql, string $unit ): string { + $value_cast_sql = 'second' === $unit + ? $this->get_expected_mysql_numeric_cast_sql( $value_sql ) + : $this->get_expected_mysql_integer_cast_sql( $value_sql ); + + return sprintf( 'CAST(%s AS double precision)', $value_cast_sql ); + } + + /** + * Get expected PostgreSQL SQL for a supported MySQL WEEK() mode. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @param int $mode MySQL WEEK() mode. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_sql( string $expression_sql, int $mode ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + + switch ( $mode ) { + case 0: + return $this->get_expected_mysql_sunday_week_mode_zero_sql( $timestamp_sql ); + + case 1: + return $this->get_expected_mysql_week_mode_one_timestamp_sql( $timestamp_sql ); + + case 2: + return $this->get_expected_mysql_sunday_week_mode_two_sql( $timestamp_sql ); + + case 3: + return $this->get_expected_mysql_iso_week_timestamp_sql( $timestamp_sql ); + + case 4: + return $this->get_expected_mysql_sunday_week_mode_four_sql( $timestamp_sql ); + + case 5: + return $this->get_expected_mysql_monday_week_mode_five_sql( $timestamp_sql ); + + case 6: + return $this->get_expected_mysql_sunday_week_mode_six_sql( $timestamp_sql ); + + case 7: + return $this->get_expected_mysql_monday_week_mode_seven_sql( $timestamp_sql ); + } + + throw new InvalidArgumentException( 'Unsupported MySQL WEEK() mode.' ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 1). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_mode_one_sql( string $expression_sql ): string { + return $this->get_expected_mysql_week_mode_one_timestamp_sql( + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(timestamp, 1). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_mode_one_timestamp_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( + "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + $year_start_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a zero-padded MySQL week expression. + * + * @param string $week_sql PostgreSQL integer week expression. + * @return string PostgreSQL text expression. + */ + private function get_expected_mysql_zero_padded_week_sql( string $week_sql ): string { + return sprintf( "LPAD(CAST(%s AS text), 2, '0')", $week_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 0). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_zero_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 2). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_two_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 3). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_iso_week_timestamp_sql( string $timestamp_sql ): string { + return sprintf( "CAST(TO_CHAR(%s, 'IW') AS integer)", $timestamp_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 4). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_four_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 5). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_monday_week_mode_five_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_monday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 6). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_six_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $next_year_start_sql = sprintf( "(%s + INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $previous_year_start_sql ); + $next_first_week_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $next_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s >= %5$s THEN 1 WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql, + $next_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 7). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_monday_week_mode_seven_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_monday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_monday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%X'). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL text expression. + */ + private function get_expected_mysql_sunday_week_mode_two_year_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %2\$s < %3\$s THEN TO_CHAR(%4\$s - INTERVAL '1 year', 'YYYY') ELSE TO_CHAR(%4\$s, 'YYYY') END", + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the Sunday-start week containing a timestamp. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_sunday_week_start_sql( string $timestamp_sql ): string { + return sprintf( + "(DATE_TRUNC('day', %1\$s) - (CAST(EXTRACT(DOW FROM %1\$s) AS integer) * INTERVAL '1 day'))", + $timestamp_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Sunday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_sunday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(7 - CAST(EXTRACT(DOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Sunday-start week with four days in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_sunday_four_day_week_of_year_sql( string $year_start_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $year_start_sql ); + + return sprintf( + "(CASE WHEN EXTRACT(DOW FROM %1\$s) <= 3 THEN %2\$s ELSE %2\$s + INTERVAL '1 week' END)", + $year_start_sql, + $week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Monday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_monday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(8 - CAST(EXTRACT(ISODOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL weekday index function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_weekday_index_sql( string $function_name, string $expression_sql ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + + if ( 'dayofweek' === $function_name ) { + return sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ); + } + + return sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + } + + /** + * Get expected PostgreSQL SQL for a supported MySQL DATE_FORMAT() format. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_sql( string $format, string $expression_sql ): string { + if ( '%H.%i' === $format ) { + return $this->get_expected_mysql_date_format_hour_minute_sql( $expression_sql ); + } + + if ( '%Y-%m-%d' === $format ) { + return $this->get_expected_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + throw new InvalidArgumentException( 'Unsupported test date format.' ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_hour_minute_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, \'HH24.MI\') AS double precision) END', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $zero_date_format_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%Y-%m-%d'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_year_month_day_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN SUBSTRING(%3$s FROM 1 FOR 10) ELSE TO_CHAR(%4$s, \'YYYY-MM-DD\') END', + $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE(expr). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_sql( string $expression_sql ): string { + return $this->get_expected_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATEDIFF(expr1, expr2). + * + * @param string $start_sql PostgreSQL start expression SQL. + * @param string $end_sql PostgreSQL end expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_datediff_sql( string $start_sql, string $end_sql ): string { + return sprintf( + 'CAST((CAST(%1$s AS date) - CAST(%2$s AS date)) AS integer)', + $this->get_expected_zero_date_safe_timestamp_sql( $start_sql ), + $this->get_expected_zero_date_safe_timestamp_sql( $end_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL TIMESTAMPDIFF(unit, expr1, expr2). + * + * @param string $unit Normalized TIMESTAMPDIFF unit. + * @param string $start_sql PostgreSQL start expression SQL. + * @param string $end_sql PostgreSQL end expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_timestampdiff_sql( string $unit, string $start_sql, string $end_sql ): string { + $start_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $start_sql ); + $end_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $end_sql ); + + if ( 'microsecond' === $unit ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) * 1000000) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql + ); + } + + $seconds_per_unit = array( + 'second' => 1, + 'minute' => 60, + 'hour' => 3600, + 'day' => 86400, + 'week' => 604800, + ); + if ( isset( $seconds_per_unit[ $unit ] ) ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) / %3$d) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $seconds_per_unit[ $unit ] + ); + } + + $month_sql = $this->get_expected_mysql_timestampdiff_month_sql( $start_timestamp_sql, $end_timestamp_sql ); + if ( 'month' === $unit ) { + return $month_sql; + } + + if ( 'quarter' === $unit ) { + return sprintf( 'CAST(TRUNC((%s)::numeric / 3) AS bigint)', $month_sql ); + } + + return sprintf( 'CAST(TRUNC((%s)::numeric / 12) AS bigint)', $month_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL TIMESTAMPDIFF(MONTH, ...). + * + * @param string $start_timestamp_sql Zero-date-safe start timestamp SQL. + * @param string $end_timestamp_sql Zero-date-safe end timestamp SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_timestampdiff_month_sql( string $start_timestamp_sql, string $end_timestamp_sql ): string { + $month_delta_sql = sprintf( + '((CAST(EXTRACT(YEAR FROM %2$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %2$s) AS integer)) - (CAST(EXTRACT(YEAR FROM %1$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %1$s) AS integer)))', + $start_timestamp_sql, + $end_timestamp_sql + ); + $start_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $start_timestamp_sql ); + $end_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $end_timestamp_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s >= %1$s THEN (%3$s - CASE WHEN %4$s < %5$s THEN 1 ELSE 0 END) ELSE (%3$s + CASE WHEN %4$s > %5$s THEN 1 ELSE 0 END) END AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $month_delta_sql, + $end_remainder_sql, + $start_remainder_sql + ); + } + + /** + * Get expected zero-date-safe PostgreSQL date/time extract SQL. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_expected_zero_date_condition_sql( $expression_text_sql ); + + if ( 'MICROSECOND' === $unit ) { + return sprintf( + "CASE WHEN %1\$s THEN NULL WHEN %2\$s THEN CAST(%3\$s AS integer) ELSE CAST(TO_CHAR(%4\$s, 'US') AS integer) END", + $empty_date_condition, + $zero_date_condition, + $this->get_expected_zero_date_microsecond_sql( $expression_text_sql ), + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN %3$s ELSE CAST(EXTRACT(%4$s FROM %5$s) AS integer) END', + $empty_date_condition, + $zero_date_condition, + $this->get_expected_zero_date_extract_part_sql( $unit, $expression_text_sql ), + $unit, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL that casts a MySQL date/time without casting zero dates. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', + $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL text SQL for a temporal expression comparison operand. + * + * @param string $expression_sql PostgreSQL temporal expression SQL. + * @param bool $returns_timestamp Whether the expression SQL is already a timestamp. + * @return string PostgreSQL text expression SQL. + */ + private function get_expected_temporal_expression_comparison_text_sql( string $expression_sql, bool $returns_timestamp ): string { + if ( $returns_timestamp ) { + return sprintf( + "TO_CHAR(%s, 'YYYY-MM-DD HH24:MI:SS')", + $expression_sql + ); + } + + return sprintf( + "CASE WHEN CAST(%1\$s AS text) IS NULL THEN NULL WHEN CAST(%1\$s AS text) = '' THEN '' WHEN CAST(%1\$s AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN CAST(%1\$s AS text) || ' 00:00:00' ELSE CAST(%1\$s AS text) END", + $expression_sql + ); + } + + /** + * Get expected condition that detects MySQL empty temporal strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_expected_empty_temporal_condition_sql( string $expression_text_sql ): string { + return sprintf( "%s = ''", $expression_text_sql ); + } + + /** + * Get expected condition that detects MySQL zero or partial-zero date strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_expected_zero_date_condition_sql( string $expression_text_sql ): string { + return sprintf( + '%1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}\' AND (SUBSTRING(%1$s FROM 1 FOR 4) = \'0000\' OR SUBSTRING(%1$s FROM 6 FOR 2) = \'00\' OR SUBSTRING(%1$s FROM 9 FOR 2) = \'00\')', + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL SQL that extracts one part from a zero-ish date string. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_extract_part_sql( string $unit, string $expression_text_sql ): string { + switch ( $unit ) { + case 'DOY': + return 'NULL'; + + case 'YEAR': + return sprintf( 'CAST(SUBSTRING(%s FROM 1 FOR 4) AS integer)', $expression_text_sql ); + + case 'MONTH': + return sprintf( 'CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer)', $expression_text_sql ); + + case 'QUARTER': + return sprintf( 'CAST(FLOOR((CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer) + 2) / 3.0) AS integer)', $expression_text_sql ); + + case 'DAY': + return sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ); + + case 'HOUR': + $start = 12; + break; + + case 'MINUTE': + $start = 15; + break; + + case 'SECOND': + $start = 18; + break; + + default: + throw new InvalidArgumentException( 'Unsupported test extract unit.' ); + } + + return sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM %2$d FOR 2) AS integer) ELSE 0 END', + $expression_text_sql, + $start + ); + } + + /** + * Get expected PostgreSQL SQL for the microsecond component of a zero-ish date string. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_microsecond_sql( string $expression_text_sql ): string { + return sprintf( + "CASE WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING(%1\$s FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", + $expression_text_sql + ); + } + + /** + * Check whether a relation is visible on the active PostgreSQL search path. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $relation_name Relation name. + * @return bool Whether the relation exists. + */ + private function postgresql_relation_exists( WP_PostgreSQL_Driver $driver, string $relation_name ): bool { + $stmt = $driver->get_connection()->query( + 'SELECT pg_catalog.to_regclass(CAST(? AS text))', + array( $relation_name ) + ); + + return null !== $stmt->fetchColumn(); + } + + /** + * Get last PostgreSQL queries without catalog-maintenance fixture noise. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @return array[] PostgreSQL queries. + */ + private function get_last_schema_postgresql_queries( WP_PostgreSQL_Driver $driver ): array { + return array_values( + array_filter( + $driver->get_last_postgresql_queries(), + static function ( array $query ): bool { + $sql = ltrim( (string) $query['sql'] ); + return 0 !== strpos( $sql, 'WITH index_columns AS' ) + && 0 !== strpos( $sql, 'SELECT pg_catalog.col_description' ) + && 0 !== strpos( $sql, 'COMMENT ON COLUMN ' ) + && 0 !== strpos( $sql, 'COMMENT ON CONSTRAINT ' ); + } + ) + ); + } + + /** + * 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; + } + + /** + * 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 MySQL-facing metadata for direct information_schema SELECT tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_direct_information_schema_options_metadata( WP_PostgreSQL_Driver $driver ): void { + $this->install_mysql_schema_metadata_fixture( + $driver, + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + )" + ); + } + + /** + * Get the WordPress core schema used for catalog-recoverability coverage. + * + * @return string MySQL CREATE TABLE statements. + */ + private function get_wordpress_core_schema(): string { + return implode( + ' ', + array( + "CREATE TABLE wp_users ( ID bigint(20) unsigned NOT NULL auto_increment, user_login varchar(60) NOT NULL default '', user_pass varchar(255) NOT NULL default '', user_nicename varchar(50) NOT NULL default '', user_email varchar(100) NOT NULL default '', user_url varchar(100) NOT NULL default '', user_registered datetime NOT NULL default '0000-00-00 00:00:00', user_activation_key varchar(255) NOT NULL default '', user_status int(11) NOT NULL default '0', display_name varchar(250) NOT NULL default '', PRIMARY KEY (ID), KEY user_login_key (user_login), KEY user_nicename (user_nicename), KEY user_email (user_email) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_usermeta ( umeta_id bigint(20) unsigned NOT NULL auto_increment, user_id bigint(20) unsigned NOT NULL default '0', meta_key varchar(255) default NULL, meta_value longtext, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_termmeta ( meta_id bigint(20) unsigned NOT NULL auto_increment, term_id bigint(20) unsigned NOT NULL default '0', meta_key varchar(255) default NULL, meta_value longtext, PRIMARY KEY (meta_id), KEY term_id (term_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_terms ( term_id bigint(20) unsigned NOT NULL auto_increment, name varchar(200) NOT NULL default '', slug varchar(200) NOT NULL default '', term_group bigint(10) NOT NULL default 0, PRIMARY KEY (term_id), KEY slug (slug(191)), KEY name (name(191)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_term_taxonomy ( term_taxonomy_id bigint(20) unsigned NOT NULL auto_increment, term_id bigint(20) unsigned NOT NULL default 0, taxonomy varchar(32) NOT NULL default '', description longtext NOT NULL, parent bigint(20) unsigned NOT NULL default 0, count bigint(20) NOT NULL default 0, PRIMARY KEY (term_taxonomy_id), UNIQUE KEY term_id_taxonomy (term_id,taxonomy), KEY taxonomy (taxonomy) ) DEFAULT CHARACTER SET utf8mb4;", + '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;', + "CREATE TABLE wp_commentmeta ( meta_id bigint(20) unsigned NOT NULL auto_increment, comment_id bigint(20) unsigned NOT NULL default '0', meta_key varchar(255) default NULL, meta_value longtext, PRIMARY KEY (meta_id), KEY comment_id (comment_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_comments ( comment_ID bigint(20) unsigned NOT NULL auto_increment, comment_post_ID bigint(20) unsigned NOT NULL default '0', comment_author tinytext NOT NULL, comment_author_email varchar(100) NOT NULL default '', comment_author_url varchar(200) NOT NULL default '', comment_author_IP varchar(100) NOT NULL default '', comment_date datetime NOT NULL default '0000-00-00 00:00:00', comment_date_gmt datetime NOT NULL default '0000-00-00 00:00:00', comment_content text NOT NULL, comment_karma int(11) NOT NULL default '0', comment_approved varchar(20) NOT NULL default '1', comment_agent varchar(255) NOT NULL default '', comment_type varchar(20) NOT NULL default 'comment', comment_parent bigint(20) unsigned NOT NULL default '0', user_id bigint(20) unsigned NOT NULL default '0', PRIMARY KEY (comment_ID), KEY comment_post_ID (comment_post_ID), KEY comment_approved_date_gmt (comment_approved,comment_date_gmt), KEY comment_date_gmt (comment_date_gmt), KEY comment_parent (comment_parent), KEY comment_author_email (comment_author_email(10)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_links ( link_id bigint(20) unsigned NOT NULL auto_increment, link_url varchar(255) NOT NULL default '', link_name varchar(255) NOT NULL default '', link_image varchar(255) NOT NULL default '', link_target varchar(25) NOT NULL default '', link_description varchar(255) NOT NULL default '', link_visible varchar(20) NOT NULL default 'Y', link_owner bigint(20) unsigned NOT NULL default '1', link_rating int(11) NOT NULL default '0', link_updated datetime NOT NULL default '0000-00-00 00:00:00', link_rel varchar(255) NOT NULL default '', link_notes mediumtext NOT NULL, link_rss varchar(255) NOT NULL default '', PRIMARY KEY (link_id), KEY link_visible (link_visible) ) DEFAULT CHARACTER SET utf8mb4;", + "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;", + "CREATE TABLE wp_postmeta ( meta_id bigint(20) unsigned NOT NULL auto_increment, post_id bigint(20) unsigned NOT NULL default '0', meta_key varchar(255) default NULL, meta_value longtext, PRIMARY KEY (meta_id), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4;", + "CREATE TABLE wp_posts ( ID bigint(20) unsigned NOT NULL auto_increment, post_author bigint(20) unsigned NOT NULL default '0', post_date datetime NOT NULL default '0000-00-00 00:00:00', post_date_gmt datetime NOT NULL default '0000-00-00 00:00:00', post_content longtext NOT NULL, post_title text NOT NULL, post_excerpt text NOT NULL, post_status varchar(20) NOT NULL default 'publish', comment_status varchar(20) NOT NULL default 'open', ping_status varchar(20) NOT NULL default 'open', post_password varchar(255) NOT NULL default '', post_name varchar(200) NOT NULL default '', to_ping text NOT NULL, pinged text NOT NULL, post_modified datetime NOT NULL default '0000-00-00 00:00:00', post_modified_gmt datetime NOT NULL default '0000-00-00 00:00:00', post_content_filtered longtext NOT NULL, post_parent bigint(20) unsigned NOT NULL default '0', guid varchar(255) NOT NULL default '', menu_order int(11) NOT NULL default '0', post_type varchar(20) NOT NULL default 'post', post_mime_type varchar(100) NOT NULL default '', comment_count bigint(20) NOT NULL default '0', PRIMARY KEY (ID), KEY post_name (post_name(191)), KEY type_status_date (post_type,post_status,post_date,ID), KEY post_parent (post_parent), KEY post_author (post_author) ) DEFAULT CHARACTER SET utf8mb4;", + ) + ); + } +} + +/** + * PDOStatement test double for result materialization tests. + */ +class WP_PostgreSQL_Result_Materialization_Test_Statement extends PDOStatement { + /** + * Fetch mode values received by fetch(). + * + * @var array + */ + public $fetch_modes = array(); + + /** + * Fetch mode values received by fetchAll(). + * + * @var array + */ + public $fetch_all_modes = array(); + + /** + * Additional argument lists received by fetchAll(). + * + * @var array + */ + public $fetch_all_args = array(); + + /** + * Rows returned by fetch(). + * + * @var array + */ + private $fetch_rows; + + /** + * Current fetch() offset. + * + * @var int + */ + private $fetch_position = 0; + + /** + * Rows returned by fetchAll(). + * + * @var array + */ + private $fetch_all_rows; + + /** + * Constructor. + * + * @param array $fetch_rows Rows returned one-by-one by fetch(). + * @param array $fetch_all_rows Rows returned by fetchAll(). + */ + public function __construct( array $fetch_rows, array $fetch_all_rows = array() ) { + $this->fetch_rows = $fetch_rows; + $this->fetch_all_rows = $fetch_all_rows; + } + + /** + * Fetch the next row. + * + * @param int|null $mode Fetch mode. + * @param int $cursorOrientation Cursor orientation. + * @param int $cursorOffset Cursor offset. + * @return mixed Next row, or false when exhausted. + */ + // phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase + #[\ReturnTypeWillChange] + public function fetch( $mode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0 ) { + $this->fetch_modes[] = $mode; + if ( ! array_key_exists( $this->fetch_position, $this->fetch_rows ) ) { + return false; + } + + return $this->fetch_rows[ $this->fetch_position++ ]; + } + // phpcs:enable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase + + /** + * Fetch all rows. + * + * @param int|null $mode Fetch mode. + * @param mixed ...$args Additional fetch mode arguments. + * @return array Rows. + */ + #[\ReturnTypeWillChange] + public function fetchAll( $mode = null, ...$args ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->fetch_all_modes[] = $mode; + $this->fetch_all_args[] = $args; + return $this->fetch_all_rows; + } +} + +/** + * Query-counting connection for backend-fallthrough assertions. + */ +class WP_PostgreSQL_Query_Spy_Connection extends WP_PostgreSQL_Connection { + /** + * No-op PDO facade for constructor-only driver attribute access. + * + * @var PDO + */ + private $pdo; + + /** + * Number of backend queries attempted. + * + * @var int + */ + private $query_count = 0; + + /** + * Constructor. + */ + public function __construct() { + $this->pdo = new class() extends PDO { + public function __construct() {} + + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + return 'pgsql'; + } + if ( PDO::ATTR_SERVER_VERSION === $attribute ) { + return 'PostgreSQL'; + } + return null; + } + + #[\ReturnTypeWillChange] + public function setAttribute( $attribute, $value ) { + unset( $attribute, $value ); + return true; + } + }; + } + + /** + * Get the no-op PDO facade. + * + * @return PDO + */ + public function get_pdo(): PDO { + return $this->pdo; + } + + /** + * Quote a value without requiring a real backend connection. + * + * @param mixed $value Value to quote. + * @param int $type PDO parameter type. + * @return string Quoted value. + */ + public function quote( $value, int $type = PDO::PARAM_STR ): string { + unset( $type ); + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + + /** + * Execute a query and count backend attempts. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + ++$this->query_count; + throw new RuntimeException( 'Unexpected backend query in PostgreSQL query spy: ' . $sql ); + } + + /** + * Get the backend query attempt count. + * + * @return int Query count. + */ + public function get_query_count(): int { + return $this->query_count; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php new file mode 100644 index 000000000..1d1a493c3 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php @@ -0,0 +1,496 @@ +run_isolated_install_script( + <<<'PHP' +require_once getcwd() . '/bootstrap-postgresql.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\" __wp_mysql_longtext NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + 'CREATE INDEX "wp_options__autoload" ON "wp_options" ("autoload")', + ), + $result['queries'] + ); + } + + /** + * Tests schema installation routes original MySQL DDL through the PostgreSQL driver. + */ + public function test_postgresql_make_db_current_silent_routes_schema_through_driver(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' + require_once getcwd() . '/bootstrap-postgresql.php'; + + class WP_PostgreSQL_Install_Test_Driver extends WP_PostgreSQL_Driver { + public $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return 0; + } + } + + $root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; + register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); + mkdir( $root . 'wp-admin/includes', 0777, true ); + file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'dbh = $driver; + } + + public function query( $statement ) { + $this->fallback_query_called = true; + throw new RuntimeException( '$wpdb->query() should not receive translated PostgreSQL DDL.' ); + } + }; + + require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + + $result = postgresql_make_db_current_silent(); + + wp_pg_install_test_remove_tree( $root ); + wp_pg_install_test_respond( + array( + 'result' => $result, + 'queries' => $driver->queries, + 'fallback_query_called' => $GLOBALS['wpdb']->fallback_query_called, + ) + ); + PHP + ); + + $this->assertTrue( $result['result'] ); + $this->assertFalse( $result['fallback_query_called'] ); + $this->assertCount( 1, $result['queries'] ); + $this->assertStringStartsWith( 'CREATE TABLE wp_options', $result['queries'][0] ); + $this->assertStringContainsString( 'UNIQUE KEY option_name (option_name)', $result['queries'][0] ); + } + + /** + * 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-postgresql.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\" __wp_mysql_longtext 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-postgresql.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" (SUBSTR(CAST("domain" AS text), 1, 140), SUBSTR(CAST("path" AS text), 1, 51))', + ), + $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 efbb40e85..2125d616a 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -4304,7 +4304,7 @@ public function testTranslateLikeBinary() { 'CREATE TABLE _tmp_table ( ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, name varchar(20) - )' + )' ); // Insert data into the table diff --git a/packages/mysql-on-sqlite/tests/bootstrap-common.php b/packages/mysql-on-sqlite/tests/bootstrap-common.php new file mode 100644 index 000000000..ab6e5f29a --- /dev/null +++ b/packages/mysql-on-sqlite/tests/bootstrap-common.php @@ -0,0 +1,136 @@ +setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + + if ( 'pgsql' !== $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + throw new RuntimeException( 'PGSQL_TEST_DSN must use the pgsql PDO driver.' ); + } + + $schema = 'wp_pg_test_' . strtolower( bin2hex( random_bytes( 8 ) ) ); + $schema_sql = WP_PostgreSQL_Connection::quote_identifier_value( $schema ); + + $pdo->exec( 'CREATE SCHEMA ' . $schema_sql ); + $pdo->exec( 'SET search_path TO ' . $schema_sql . ', public' ); + + register_shutdown_function( + static function () use ( $pdo, $schema_sql ): void { + try { + if ( $pdo->inTransaction() ) { + $pdo->rollBack(); + } + + $pdo->exec( 'DROP SCHEMA IF EXISTS ' . $schema_sql . ' CASCADE' ); + } catch ( Throwable $e ) { + // Cleanup should not mask the isolated script result. + } + } + ); + + return $pdo; + } +} + +// Configure the test environment. +error_reporting( E_ALL ); + +// Polyfill WPDB globals. +$GLOBALS['table_prefix'] = 'wptests_'; +$GLOBALS['wpdb'] = new class() { + public function set_prefix( string $prefix ): void {} +}; + +/** + * Polyfills for WordPress functions + */ +if ( ! function_exists( 'do_action' ) ) { + /** + * Polyfill the do_action function. + */ + function do_action() {} +} + +if ( ! function_exists( 'apply_filters' ) ) { + /** + * Polyfill the apply_filters function. + * + * @param string $tag The filter name. + * @param mixed $value The value to filter. + * @param mixed ...$args Additional arguments to pass to the filter. + * + * @return mixed Returns $value. + */ + function apply_filters( $tag, $value, ...$args ) { + return $value; + } +} + +if ( extension_loaded( 'mbstring' ) ) { + + if ( ! function_exists( 'mb_str_starts_with' ) ) { + /** + * Polyfill for mb_str_starts_with. + * + * @param string $haystack The string to search in. + * @param string $needle The string to search for. + * + * @return bool + */ + function mb_str_starts_with( string $haystack, string $needle ) { + return empty( $needle ) || 0 === mb_strpos( $haystack, $needle ); + } + } + + if ( ! function_exists( 'mb_str_contains' ) ) { + /** + * Polyfill for mb_str_contains. + * + * @param string $haystack The string to search in. + * @param string $needle The string to search for. + * + * @return bool + */ + function mb_str_contains( string $haystack, string $needle ) { + return empty( $needle ) || false !== mb_strpos( $haystack, $needle ); + } + } + + if ( ! function_exists( 'mb_str_ends_with' ) ) { + /** + * Polyfill for mb_str_ends_with. + * + * @param string $haystack The string to search in. + * @param string $needle The string to search for. + * + * @return bool + */ + function mb_str_ends_with( string $haystack, string $needle ) { + return empty( $needle ) || mb_substr( $haystack, - mb_strlen( $needle ) ) === $needle; + } + } +} diff --git a/packages/mysql-on-sqlite/tests/bootstrap-postgresql.php b/packages/mysql-on-sqlite/tests/bootstrap-postgresql.php new file mode 100644 index 000000000..14af637d5 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/bootstrap-postgresql.php @@ -0,0 +1,3 @@ + $arg ) { + $arg = (string) $arg; + if ( false !== strpos( $arg, 'WP_PostgreSQL_' ) || false !== strpos( $arg, 'phpunit-postgresql.xml' ) ) { + return true; + } + + if ( + 'postgresql' === $arg + && isset( $args[ $index - 1 ] ) + && in_array( (string) $args[ $index - 1 ], array( '--testsuite', '--testsuites' ), true ) + ) { + return true; + } + + if ( 0 === strpos( $arg, '--testsuite=postgresql' ) || 0 === strpos( $arg, '--testsuites=postgresql' ) ) { + return true; + } + } + + return false; +}; + +if ( $postgresql_test_invocation() ) { + require_once __DIR__ . '/bootstrap-postgresql.php'; + return; +} + require_once __DIR__ . '/wp-sqlite-schema.php'; -require_once __DIR__ . '/../src/load.php'; +require_once __DIR__ . '/bootstrap-common.php'; // When on an older SQLite version, enable unsafe back compatibility. $sqlite_version = ( new PDO( 'sqlite::memory:' ) )->query( 'SELECT SQLITE_VERSION();' )->fetch()[0]; @@ -9,88 +38,5 @@ define( 'WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS', true ); } -if ( '1' === getenv( 'WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION' ) ) { - require_once __DIR__ . '/tools/verify-native-parser-extension.php'; -} - -// Configure the test environment. -error_reporting( E_ALL ); define( 'FQDB', ':memory:' ); define( 'FQDBDIR', __DIR__ . '/../testdb' ); - -// Polyfill WPDB globals. -$GLOBALS['table_prefix'] = 'wptests_'; -$GLOBALS['wpdb'] = new class() { - public function set_prefix( string $prefix ): void {} -}; - -/** - * Polyfills for WordPress functions - */ -if ( ! function_exists( 'do_action' ) ) { - /** - * Polyfill the do_action function. - */ - function do_action() {} -} - -if ( ! function_exists( 'apply_filters' ) ) { - /** - * Polyfill the apply_filters function. - * - * @param string $tag The filter name. - * @param mixed $value The value to filter. - * @param mixed ...$args Additional arguments to pass to the filter. - * - * @return mixed Returns $value. - */ - function apply_filters( $tag, $value, ...$args ) { - return $value; - } -} - -if ( extension_loaded( 'mbstring' ) ) { - - if ( ! function_exists( 'mb_str_starts_with' ) ) { - /** - * Polyfill for mb_str_starts_with. - * - * @param string $haystack The string to search in. - * @param string $needle The string to search for. - * - * @return bool - */ - function mb_str_starts_with( string $haystack, string $needle ) { - return empty( $needle ) || 0 === mb_strpos( $haystack, $needle ); - } - } - - if ( ! function_exists( 'mb_str_contains' ) ) { - /** - * Polyfill for mb_str_contains. - * - * @param string $haystack The string to search in. - * @param string $needle The string to search for. - * - * @return bool - */ - function mb_str_contains( string $haystack, string $needle ) { - return empty( $needle ) || false !== mb_strpos( $haystack, $needle ); - } - } - - if ( ! function_exists( 'mb_str_ends_with' ) ) { - /** - * Polyfill for mb_str_ends_with. - * - * @param string $haystack The string to search in. - * @param string $needle The string to search for. - * - * @return bool - */ - function mb_str_ends_with( string $haystack, string $needle ) { - // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found - return empty( $needle ) || $needle = mb_substr( $haystack, - mb_strlen( $needle ) ); - } - } -} diff --git a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php index 383b03f57..ecda87ada 100644 --- a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php @@ -325,6 +325,64 @@ public function data_valid_escaped_strings(): array { ); } + public function test_double_quoted_text_without_ansi_quotes_remains_string(): void { + $lexer = new WP_MySQL_Lexer( '"my_column"' ); + $this->assertFalse( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_ANSI_QUOTES ) ); + + $this->assertTrue( $lexer->next_token() ); + $token = $lexer->get_token(); + + $this->assertSame( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, $token->id ); + $this->assertSame( 'my_column', $token->get_value() ); + } + + public function test_double_quoted_text_with_ansi_quotes_is_identifier_like(): void { + $lexer = new WP_MySQL_Lexer( '"my_column"', 80038, array( 'ANSI_QUOTES' ) ); + $this->assertTrue( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_ANSI_QUOTES ) ); + + $this->assertTrue( $lexer->next_token() ); + $token = $lexer->get_token(); + + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $token->id ); + $this->assertSame( 'my_column', $token->get_value() ); + } + + public function test_no_backslash_escapes_sql_mode_preserves_backslash_sequences(): void { + $lexer = new WP_MySQL_Lexer( "'\\n'" ); + $this->assertFalse( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_NO_BACKSLASH_ESCAPES ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( "\n", $lexer->get_token()->get_value() ); + + $lexer = new WP_MySQL_Lexer( "'\\n'", 80038, array( 'NO_BACKSLASH_ESCAPES' ) ); + $this->assertTrue( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_NO_BACKSLASH_ESCAPES ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( '\\n', $lexer->get_token()->get_value() ); + } + + public function test_pipes_as_concat_sql_mode_changes_double_pipe_token(): void { + $tokens = ( new WP_MySQL_Lexer( '1 || 0' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, $tokens[1]->id ); + + $tokens = ( new WP_MySQL_Lexer( '1 || 0', 80038, array( 'PIPES_AS_CONCAT' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::CONCAT_PIPES_SYMBOL, $tokens[1]->id ); + } + + public function test_ignore_space_sql_mode_allows_whitespace_before_function_call_parentheses(): void { + $tokens = ( new WP_MySQL_Lexer( 'COUNT (*)' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::IDENTIFIER, $tokens[0]->id ); + + $tokens = ( new WP_MySQL_Lexer( 'COUNT (*)', 80038, array( 'IGNORE_SPACE' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::COUNT_SYMBOL, $tokens[0]->id ); + } + + public function test_high_not_precedence_sql_mode_emits_not2_token(): void { + $tokens = ( new WP_MySQL_Lexer( 'NOT 1' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::NOT_SYMBOL, $tokens[0]->id ); + + $tokens = ( new WP_MySQL_Lexer( 'NOT 1', 80038, array( 'HIGH_NOT_PRECEDENCE' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::NOT2_SYMBOL, $tokens[0]->id ); + } + /** * Test that a chunk boundary splitting a quoted string with a trailing * backslash does not cause an out-of-bounds string access. diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index b231344d9..0deec29f7 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -1,6 +1,8 @@ { "name": "wordpress/mysql-proxy", + "description": "A MySQL proxy that bridges the MySQL wire protocol to a PDO-like interface.", "type": "library", + "license": "GPL-2.0-or-later", "bin": [ "bin/wp-mysql-proxy.php" ], diff --git a/packages/mysql-proxy/src/class-mysql-protocol.php b/packages/mysql-proxy/src/class-mysql-protocol.php index 8423394ab..66d5c97e0 100644 --- a/packages/mysql-proxy/src/class-mysql-protocol.php +++ b/packages/mysql-proxy/src/class-mysql-protocol.php @@ -590,7 +590,7 @@ public static function read_length_encoded_int( string $payload, int &$offset ): $value = unpack( 'v', $payload, $offset )[1]; $offset += 2; } elseif ( 0xfd === $first_byte ) { - $value = unpack( 'VX', $payload, $offset )[1]; + $value = unpack( 'V', substr( $payload, $offset, 3 ) . "\0" )[1]; $offset += 3; } else { $value = unpack( 'P', $payload, $offset )[1]; diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php index b4504a030..103ed4ecc 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -1,6 +1,7 @@ skip_if_missing_test_client(); + $this->server = new MySQL_Server_Process( array( 'port' => $this->port, @@ -19,6 +22,10 @@ public function setUp(): void { } public function tearDown(): void { + if ( ! $this->server instanceof MySQL_Server_Process ) { + return; + } + $this->server->stop(); $exit_code = $this->server->get_exit_code(); if ( $this->hasFailed() || ( $exit_code > 0 && 143 !== $exit_code ) ) { @@ -32,4 +39,26 @@ public function tearDown(): void { ); } } + + private function skip_if_missing_test_client(): void { + switch ( static::class ) { + case WP_MySQL_Proxy_CLI_Test::class: + if ( null === ( new ExecutableFinder() )->find( 'mysql' ) ) { + $this->markTestSkipped( 'The mysql CLI client is not available.' ); + } + break; + + case WP_MySQL_Proxy_MySQLi_Test::class: + if ( ! class_exists( 'mysqli' ) ) { + $this->markTestSkipped( 'The mysqli extension is not available.' ); + } + break; + + case WP_MySQL_Proxy_PDO_Test::class: + if ( ! class_exists( 'PDO' ) || ! in_array( 'mysql', PDO::getAvailableDrivers(), true ) ) { + $this->markTestSkipped( 'The pdo_mysql extension is not available.' ); + } + break; + } + } } diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index 35f17fbd9..d0be38cba 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -24,6 +24,7 @@ const SQL_MODE_HIGH_NOT_PRECEDENCE: i64 = 1; const SQL_MODE_PIPES_AS_CONCAT: i64 = 2; const SQL_MODE_IGNORE_SPACE: i64 = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES: i64 = 8; +const SQL_MODE_ANSI_QUOTES: i64 = 16; const STACK_RED_ZONE: usize = 128 * 1024; const STACK_GROW_SIZE: usize = 8 * 1024 * 1024; @@ -137,6 +138,7 @@ fn sql_modes_mask(sql_modes: &[String]) -> i64 { "PIPES_AS_CONCAT" => mask |= SQL_MODE_PIPES_AS_CONCAT, "IGNORE_SPACE" => mask |= SQL_MODE_IGNORE_SPACE, "NO_BACKSLASH_ESCAPES" => mask |= SQL_MODE_NO_BACKSLASH_ESCAPES, + "ANSI_QUOTES" => mask |= SQL_MODE_ANSI_QUOTES, _ => {} } } @@ -813,6 +815,7 @@ impl WpMySqlNativeLexer { self.bytes_already_read = at + 1; Some(match quote { b'`' => lex::BACK_TICK_QUOTED_ID, + b'"' if self.is_sql_mode_active(SQL_MODE_ANSI_QUOTES) => lex::BACK_TICK_QUOTED_ID, b'"' => lex::DOUBLE_QUOTED_TEXT, _ => lex::SINGLE_QUOTED_TEXT, }) diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 15e6772a1..18d5eaed1 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -6,13 +6,31 @@ * @package wp-sqlite-integration */ +if ( ! function_exists( 'wp_sqlite_database_integration_normalize_db_engine' ) ) { + /** + * Normalizes supported database engine names. + * + * @param string $engine Database engine name. + * @return string Canonical database engine name. + */ + function wp_sqlite_database_integration_normalize_db_engine( $engine ) { + $engine = strtolower( (string) $engine ); + + if ( in_array( $engine, array( 'postgres', 'pgsql', 'postgresql' ), true ) ) { + return 'postgresql'; + } + + return $engine; + } +} + // Temporary - This will be in wp-config.php once SQLite is merged in Core. if ( ! defined( 'DB_ENGINE' ) ) { if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) { define( 'DB_ENGINE', 'sqlite' ); } elseif ( defined( 'DATABASE_ENGINE' ) ) { // backwards compatibility with previous versions of the plugin. - define( 'DB_ENGINE', DATABASE_ENGINE ); + define( 'DB_ENGINE', wp_sqlite_database_integration_normalize_db_engine( DATABASE_ENGINE ) ); } else { define( 'DB_ENGINE', 'mysql' ); } diff --git a/packages/plugin-sqlite-database-integration/db.copy b/packages/plugin-sqlite-database-integration/db.copy index ef8291374..9c83e7213 100644 --- a/packages/plugin-sqlite-database-integration/db.copy +++ b/packages/plugin-sqlite-database-integration/db.copy @@ -24,17 +24,31 @@ if ( ! $sqlite_plugin_implementation_folder_path || ! file_exists( $sqlite_plugi return; } +// Resolve the selected backend. The unreplaced placeholder keeps existing +// copy-paste installs on SQLite. +$database_engine = defined( 'DB_ENGINE' ) + ? DB_ENGINE + : ( defined( 'DATABASE_ENGINE' ) ? DATABASE_ENGINE : '{DATABASE_ENGINE}' ); +$unreplaced_database_engine = '{' . 'DATABASE_ENGINE' . '}'; +if ( $unreplaced_database_engine === $database_engine ) { + $database_engine = 'sqlite'; +} +$database_engine = strtolower( (string) $database_engine ); +if ( in_array( $database_engine, array( 'postgres', 'pgsql', 'postgresql' ), true ) ) { + $database_engine = 'postgresql'; +} + // Constant for backward compatibility. if ( ! defined( 'DATABASE_TYPE' ) ) { - define( 'DATABASE_TYPE', 'sqlite' ); + define( 'DATABASE_TYPE', $database_engine ); } -// Define SQLite constant. +// Define database engine constant. if ( ! defined( 'DB_ENGINE' ) ) { - define( 'DB_ENGINE', 'sqlite' ); + define( 'DB_ENGINE', $database_engine ); } // Require the implementation from the plugin. -require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/db.php'; +require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/db.php'; // Activate the performance-lab plugin if it is not already activated. add_action( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/db.php b/packages/plugin-sqlite-database-integration/wp-includes/db.php new file mode 100644 index 000000000..a39c3de15 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/db.php @@ -0,0 +1,22 @@ +charset ) { + $this->charset = 'utf8mb4'; + } + } + + /** + * Method to set character set for the database. + * + * @param resource $dbh The database handle. + * @param string $charset Optional. The character set. + * @param string $collate Optional. The collation. + */ + public function set_charset( $dbh, $charset = null, $collate = null ) { + if ( ! isset( $charset ) ) { + $charset = $this->charset; + } + if ( ! isset( $collate ) ) { + $collate = $this->collate; + } + + if ( ! $this->has_cap( 'collation' ) || empty( $charset ) ) { + return; + } + + if ( $dbh instanceof WP_PostgreSQL_Driver ) { + $dbh->set_charset( (string) $charset, empty( $collate ) ? null : (string) $collate ); + } + } + + /** + * Method to get the character set for the database. + * + * @param string $table The table name. + * @param string $column The column name. + * @return string The character set. + */ + public function get_col_charset( $table, $column ) { + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + $columnkey = $this->get_postgresql_metadata_key( (string) $column ); + + if ( function_exists( 'apply_filters' ) ) { + $charset = apply_filters( 'pre_get_col_charset', null, $table, $column ); + if ( null !== $charset ) { + return $charset; + } + } + + if ( empty( $this->is_mysql ) ) { + return false; + } + + if ( ! array_key_exists( $tablekey, $this->table_charset ) ) { + $table_charset = $this->get_table_charset( $table ); + if ( function_exists( 'is_wp_error' ) && is_wp_error( $table_charset ) ) { + return $table_charset; + } + } + + if ( empty( $this->col_meta[ $tablekey ] ) ) { + return $this->table_charset[ $tablekey ]; + } + + if ( empty( $this->col_meta[ $tablekey ][ $columnkey ] ) ) { + return $this->table_charset[ $tablekey ]; + } + + if ( empty( $this->col_meta[ $tablekey ][ $columnkey ]->Collation ) ) { + return false; + } + + list( $charset ) = explode( '_', $this->col_meta[ $tablekey ][ $columnkey ]->Collation ); + return $charset; + } + + /** + * Retrieves the MySQL-compatible table charset from PostgreSQL metadata. + * + * @param string $table Table name. + * @return string|false|WP_Error Table charset, false for non-text tables, or an error. + */ + protected function get_table_charset( $table ) { + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + + if ( function_exists( 'apply_filters' ) ) { + $charset = apply_filters( 'pre_get_table_charset', null, $table ); + if ( null !== $charset ) { + return $charset; + } + } + + if ( array_key_exists( $tablekey, $this->table_charset ) ) { + return $this->table_charset[ $tablekey ]; + } + + $columns = $this->get_postgresql_column_charset_metadata( (string) $table ); + if ( false === $columns ) { + return new WP_Error( 'wpdb_get_table_charset_failure', __( 'Could not retrieve table charset.' ) ); + } + + $this->col_meta[ $tablekey ] = $columns; + $this->table_charset[ $tablekey ] = $this->get_postgresql_table_charset_from_columns( $columns ); + + return $this->table_charset[ $tablekey ]; + } + + /** + * Strips invalid text using PostgreSQL-compatible PHP charset handling. + * + * WordPress core falls back to MySQL CONVERT(... USING ...) calls for + * legacy charsets. PostgreSQL does not have MySQL charset names, so mirror + * the core pre-truncation and UTF-8 paths, then emulate the conversion step + * in PHP for the charsets covered by core's charset tests. + * + * @param array $data Field data. + * @return array|WP_Error Field data with invalid text removed, or error. + */ + protected function strip_invalid_text( $data ) { + foreach ( $data as &$value ) { + $charset = $value['charset']; + + if ( is_array( $value['length'] ) ) { + $length = $value['length']['length']; + $truncate_by_byte_length = 'byte' === $value['length']['type']; + } else { + $length = false; + $truncate_by_byte_length = false; + } + + if ( false === $charset || ! is_string( $value['value'] ) ) { + continue; + } + + $needs_validation = true; + if ( + 'latin1' === $charset + || ( ! isset( $value['ascii'] ) && $this->check_ascii( $value['value'] ) ) + ) { + $truncate_by_byte_length = true; + $needs_validation = false; + } + + if ( $truncate_by_byte_length ) { + mbstring_binary_safe_encoding(); + if ( false !== $length && strlen( $value['value'] ) > $length ) { + $value['value'] = substr( $value['value'], 0, $length ); + } + reset_mbstring_encoding(); + + if ( ! $needs_validation ) { + continue; + } + } + + if ( ( 'utf8' === $charset || 'utf8mb3' === $charset || 'utf8mb4' === $charset ) && function_exists( 'mb_strlen' ) ) { + $value['value'] = $this->strip_postgresql_invalid_utf8_text( $value['value'], $charset, $length ); + continue; + } + + $stripped = $this->strip_postgresql_invalid_legacy_text( $value['value'], $charset, $value['length'] ); + if ( false === $stripped ) { + return new WP_Error( 'wpdb_strip_invalid_text_failure', __( 'Could not strip invalid text.' ) ); + } + + $value['value'] = $stripped; + } + unset( $value ); + + return $data; + } + + /** + * Gets the maximum string length for a PostgreSQL-backed column. + * + * Core wpdb skips length detection when the connection is not MySQL. The + * PostgreSQL schema translator still preserves varchar lengths, so expose + * them in the same shape wpdb::strip_invalid_text() expects. + * + * @param string $table Table name. + * @param string $column Column name. + * @return array|false Maximum length data, or false when unrestricted/unknown. + */ + public function get_col_length( $table, $column ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + $table = $this->normalize_postgresql_table_name( (string) $table ); + $column = trim( (string) $column, "`\" \t\n\r\0\x0B" ); + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + $columnkey = $this->get_postgresql_metadata_key( (string) $column ); + + $columns = array_key_exists( $tablekey, $this->col_meta ) + ? $this->col_meta[ $tablekey ] + : $this->get_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && isset( $columns[ $columnkey ] ) ) { + $length = $this->get_postgresql_column_length_from_mysql_type( + (string) $columns[ $columnkey ]->Type + ); + if ( false !== $length ) { + return $length; + } + } + + if ( + isset( $this->postgresql_column_length_cache[ $tablekey ] ) + && array_key_exists( $columnkey, $this->postgresql_column_length_cache[ $tablekey ] ) + ) { + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT data_type, character_maximum_length + FROM information_schema.columns + WHERE ( + table_schema = ( + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE oid = pg_my_temp_schema() + ) + OR table_schema = current_schema() + ) + AND table_name = ? + AND column_name = ? + ORDER BY CASE + WHEN table_schema = ( + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE oid = pg_my_temp_schema() + ) THEN 0 + ELSE 1 + END + LIMIT 1', + array( $table, $column ) + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + if ( ! is_array( $row ) ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = false; + return false; + } + + $type = strtolower( (string) ( $row['data_type'] ?? '' ) ); + $length = isset( $row['character_maximum_length'] ) ? (int) $row['character_maximum_length'] : 0; + + if ( in_array( $type, array( 'character varying', 'character', 'varchar', 'char' ), true ) && $length > 0 ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = array( + 'type' => 'char', + 'length' => $length, + ); + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + if ( 'text' === $type ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = array( + 'type' => 'byte', + 'length' => 65535, + ); + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = false; + return false; + } + + /** + * Determines the best charset/collation pair for PostgreSQL-backed wpdb. + * + * Core returns early for non-mysqli handles. The PostgreSQL backend still + * advertises MySQL-compatible charset capabilities to WordPress, so apply + * the same utf8-to-utf8mb4 upgrade rules without the mysqli guard. + * + * @param string $charset Requested charset. + * @param string $collate Requested collation. + * @return array{charset: string, collate: string} Charset/collation pair. + */ + public function determine_charset( $charset, $collate ) { + if ( empty( $this->dbh ) ) { + return compact( 'charset', 'collate' ); + } + + if ( 'utf8' === $charset ) { + $charset = 'utf8mb4'; + } + + if ( 'utf8mb4' === $charset ) { + if ( ! $collate || 'utf8_general_ci' === $collate ) { + $collate = 'utf8mb4_unicode_ci'; + } else { + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate ); + } + } + + if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + + /** + * Strip invalid UTF-8 text using WordPress core's local regex path. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param int|false $length Optional character length. + * @return string Stripped value. + */ + private function strip_postgresql_invalid_utf8_text( string $value, string $charset, $length ): string { + $regex = '/ + ( + (?: [\x00-\x7F] + | [\xC2-\xDF][\x80-\xBF] + | \xE0[\xA0-\xBF][\x80-\xBF] + | [\xE1-\xEC][\x80-\xBF]{2} + | \xED[\x80-\x9F][\x80-\xBF] + | [\xEE-\xEF][\x80-\xBF]{2}'; + + if ( 'utf8mb4' === $charset ) { + $regex .= ' + | \xF0[\x90-\xBF][\x80-\xBF]{2} + | [\xF1-\xF3][\x80-\xBF]{3} + | \xF4[\x80-\x8F][\x80-\xBF]{2} + '; + } + + $regex .= '){1,40} + ) + | . + /x'; + + $value = preg_replace( $regex, '$1', $value ); + if ( false !== $length && mb_strlen( $value, 'UTF-8' ) > $length ) { + $value = mb_substr( $value, 0, $length, 'UTF-8' ); + } + + return $value; + } + + /** + * Strip invalid text for MySQL legacy charsets using PHP conversion. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param array|false $length Optional length metadata. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_legacy_text( string $value, string $charset, $length ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + $connection_charset = $this->get_postgresql_connection_charset(); + + if ( is_array( $length ) && 'byte' === $length['type'] ) { + return $this->strip_postgresql_invalid_trailing_bytes( $value, $connection_charset ); + } + + if ( $charset === $connection_charset && $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + if ( is_array( $length ) ) { + return substr( $value, 0, (int) $length['length'] ); + } + + return $value; + } + + if ( ! function_exists( 'mb_convert_encoding' ) ) { + return false; + } + + $target_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + $connection_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $connection_charset ); + if ( null === $target_encoding || null === $connection_encoding ) { + return false; + } + + $target_value = $value; + if ( $target_encoding !== $connection_encoding ) { + $target_value = mb_convert_encoding( $value, $target_encoding, $connection_encoding ); + } + + if ( is_array( $length ) ) { + $target_value = mb_substr( $target_value, 0, (int) $length['length'], $target_encoding ); + } + + if ( $target_encoding === $connection_encoding ) { + return $this->strip_postgresql_invalid_trailing_bytes( $target_value, $connection_charset ); + } + + return mb_convert_encoding( $target_value, $connection_encoding, $target_encoding ); + } + + /** + * Strip a partial trailing multibyte sequence after byte truncation. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_trailing_bytes( string $value, string $charset ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + if ( $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + return $value; + } + + $encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + if ( null === $encoding || ! function_exists( 'mb_check_encoding' ) ) { + return false; + } + + while ( '' !== $value && ! mb_check_encoding( $value, $encoding ) ) { + $value = substr( $value, 0, -1 ); + } + + return $value; + } + + /** + * Get the current MySQL-compatible connection charset. + * + * @return string Charset. + */ + private function get_postgresql_connection_charset(): string { + if ( ! empty( $this->charset ) ) { + return $this->normalize_postgresql_mysql_charset( (string) $this->charset ); + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + return $this->normalize_postgresql_mysql_charset( $this->dbh->get_charset() ); + } + + return 'utf8mb4'; + } + + /** + * Normalize a MySQL charset for PostgreSQL adapter logic. + * + * @param string $charset Charset. + * @return string Normalized charset. + */ + private function normalize_postgresql_mysql_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Check whether a charset is single-byte for truncation purposes. + * + * @param string $charset MySQL charset. + * @return bool Whether the charset is single-byte. + */ + private function is_postgresql_single_byte_mysql_charset( string $charset ): bool { + return in_array( + $this->normalize_postgresql_mysql_charset( $charset ), + array( 'ascii', 'binary', 'cp1251', 'hebrew', 'koi8r', 'latin1', 'tis620' ), + true + ); + } + + /** + * Map a MySQL charset to a PHP mbstring encoding. + * + * @param string $charset MySQL charset. + * @return string|null PHP encoding, or null when unsupported. + */ + private function get_postgresql_php_encoding_for_mysql_charset( string $charset ): ?string { + $encodings = array( + 'ascii' => 'ASCII', + 'big5' => 'BIG-5', + 'cp1251' => 'Windows-1251', + 'hebrew' => 'ISO-8859-8', + 'koi8r' => 'KOI8-R', + 'latin1' => 'ISO-8859-1', + 'ujis' => 'EUC-JP', + 'utf8' => 'UTF-8', + 'utf8mb4' => 'UTF-8', + ); + + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + return $encodings[ $charset ] ?? null; + } + + /** + * Refresh MySQL charset metadata caches for a created PostgreSQL table. + * + * Temporary and permanent table metadata is loaded back through the driver's + * SHOW FULL COLUMNS catalog path. + * + * @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 ) ) { + return; + } + + try { + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query ); + foreach ( $metadata as $table ) { + if ( ! empty( $table['table_name'] ) ) { + $this->clear_postgresql_table_charset_cache( array( (string) $table['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 ); + } + + /** + * 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 ); + + unset( + $this->table_charset[ $tablekey ], + $this->col_meta[ $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(); + } + + /** + * Check whether a CREATE TABLE query carries MySQL charset metadata. + * + * The WordPress PostgreSQL installer executes already-translated PostgreSQL + * DDL. That SQL must not be fed back into the MySQL parser used for metadata + * extraction. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query should be parsed as MySQL charset DDL. + */ + private function is_postgresql_mysql_charset_metadata_create_query( string $query ): bool { + return 1 === preg_match( '/\b(?:CHARSET|CHARACTER\s+SET|COLLATE|ASCII|UNICODE|BINARY)\b/i', $query ); + } + + /** + * Tokenize MySQL-flavored SQL using the active PostgreSQL driver SQL modes. + * + * @param string $query MySQL-flavored SQL. + * @return WP_MySQL_Token[] Token stream. + */ + private function get_postgresql_mysql_tokens( string $query ): array { + $sql_modes = array(); + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $sql_mode = $this->dbh->get_sql_mode(); + $sql_modes = '' === $sql_mode ? array() : explode( ',', $sql_mode ); + } + + $lexer = new WP_MySQL_Lexer( $query, 80038, $sql_modes ); + return $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + } + + /** + * Check whether a CREATE TABLE query creates a temporary table. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query is CREATE TEMPORARY TABLE. + */ + private function is_postgresql_create_temporary_table_query( string $query ): bool { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return false; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + + /** + * Get the table name from a CREATE TABLE query. + * + * @param string $query CREATE TABLE query. + * @return string|null Table name, or null when unavailable. + */ + private function get_postgresql_create_table_name( string $query ): ?string { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return null; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + $qualified_table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $qualified_table_name ) { + break; + } + + $table_name = $qualified_table_name; + $position += 2; + } + + return $table_name; + } + + /** + * 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 ) { + $columns = $this->get_driver_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && ! empty( $columns ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $columns; + 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_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 MySQL charset metadata through the PostgreSQL driver's metadata API. + * + * 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 executing a SQL-level SHOW statement internally. + * + * @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; + } + + if ( ! method_exists( $this->dbh, 'get_mysql_column_charset_metadata_for_table' ) ) { + return false; + } + + try { + $metadata_rows = $this->dbh->get_mysql_column_charset_metadata_for_table( $table_name ); + } catch ( Throwable $e ) { + return false; + } + + if ( ! is_array( $metadata_rows ) || empty( $metadata_rows ) ) { + return false; + } + + return $this->format_postgresql_charset_column_rows( $metadata_rows ); + } + + /** + * Synthesize MySQL metadata from PostgreSQL catalogs when side metadata is absent. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_native_postgresql_column_charset_metadata( string $table, ?string $table_schema = null ) { + try { + $table_schema_sql = null === $table_schema ? 'current_schema()' : '?'; + $params = null === $table_schema + ? array( $this->normalize_postgresql_table_name( $table ) ) + : array( $table_schema, $this->normalize_postgresql_table_name( $table ) ); + + $stmt = $this->dbh->get_connection()->query( + 'SELECT column_name, data_type, character_maximum_length + FROM information_schema.columns + WHERE table_schema = ' . $table_schema_sql . ' + AND lower(table_name) = lower(?) + ORDER BY ordinal_position', + $params + ); + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + if ( empty( $rows ) ) { + return false; + } + + $columns = array(); + $default_collation = $this->get_postgresql_default_collation_for_charset( $this->charset ? $this->charset : 'utf8mb4' ); + + foreach ( $rows as $row ) { + $type = strtolower( (string) ( $row['data_type'] ?? '' ) ); + $length = isset( $row['character_maximum_length'] ) ? (int) $row['character_maximum_length'] : 0; + $mysqltype = $this->get_postgresql_native_mysql_column_type( $type, $length ); + $collation = in_array( $type, array( 'character varying', 'character', 'text' ), true ) ? $default_collation : null; + + $columns[] = array( + 'column_name' => (string) $row['column_name'], + 'column_type' => $mysqltype, + 'collation_name' => $collation, + ); + } + + return $this->format_postgresql_charset_column_rows( $columns ); + } + + /** + * Convert metadata rows into wpdb col_meta objects. + * + * @param array $rows Metadata rows. + * @return array Column metadata keyed by lowercase column name. + */ + private function format_postgresql_charset_column_rows( array $rows ): array { + $columns = array(); + + foreach ( $rows as $row ) { + $field = (string) ( $row['column_name'] ?? '' ); + if ( '' === $field ) { + continue; + } + + $columns[ $this->get_postgresql_metadata_key( $field ) ] = (object) array( + 'Field' => $field, + 'Type' => (string) ( $row['column_type'] ?? '' ), + 'Collation' => $row['collation_name'] ?? null, + ); + } + + return $columns; + } + + /** + * Convert a MySQL-facing column type into WordPress length metadata. + * + * @param string $column_type MySQL column type. + * @return array|false Column length metadata, or false when unrestricted/unknown. + */ + private function get_postgresql_column_length_from_mysql_type( string $column_type ) { + $typeinfo = explode( '(', $column_type, 2 ); + $type = strtolower( trim( $typeinfo[0] ) ); + $length = false; + + if ( ! empty( $typeinfo[1] ) ) { + $length = (int) trim( $typeinfo[1], ") \t\n\r\0\x0B" ); + } + + switch ( $type ) { + case 'char': + case 'varchar': + if ( false === $length || $length <= 0 ) { + return false; + } + + return array( + 'type' => 'char', + 'length' => $length, + ); + + case 'binary': + case 'varbinary': + if ( false === $length || $length <= 0 ) { + return false; + } + + return array( + 'type' => 'byte', + 'length' => $length, + ); + + case 'tinyblob': + case 'tinytext': + return array( + 'type' => 'byte', + 'length' => 255, + ); + + case 'blob': + case 'text': + return array( + 'type' => 'byte', + 'length' => 65535, + ); + + case 'mediumblob': + case 'mediumtext': + return array( + 'type' => 'byte', + 'length' => 16777215, + ); + + case 'longblob': + case 'longtext': + return array( + 'type' => 'byte', + 'length' => 4294967295, + ); + + default: + return false; + } + } + + /** + * Calculate WordPress's table charset value from column metadata. + * + * @param array $columns Column metadata. + * @return string|false Table charset, or false for tables without text columns. + */ + private function get_postgresql_table_charset_from_columns( array $columns ) { + $charsets = array(); + + foreach ( $columns as $column ) { + $collation = $column->{'Collation'}; + if ( ! empty( $collation ) ) { + list( $charset ) = explode( '_', $collation ); + + $charsets[ strtolower( $charset ) ] = true; + } + + $column_type = $column->{'Type'}; + + list( $type ) = explode( '(', $column_type ); + if ( in_array( strtoupper( $type ), array( 'BINARY', 'VARBINARY', 'TINYBLOB', 'MEDIUMBLOB', 'BLOB', 'LONGBLOB' ), true ) ) { + return 'binary'; + } + } + + if ( isset( $charsets['utf8mb3'] ) ) { + $charsets['utf8'] = true; + unset( $charsets['utf8mb3'] ); + } + + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 0 === $count ) { + return false; + } + + unset( $charsets['latin1'] ); + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 2 === $count && isset( $charsets['utf8'], $charsets['utf8mb4'] ) ) { + return 'utf8'; + } + + return 'ascii'; + } + + /** + * Convert PostgreSQL catalog types to MySQL-ish metadata types. + * + * @param string $type PostgreSQL data type. + * @param int $length Character length. + * @return string MySQL-ish column type. + */ + private function get_postgresql_native_mysql_column_type( string $type, int $length ): string { + if ( 'character varying' === $type ) { + return $length > 0 ? sprintf( 'varchar(%d)', $length ) : 'varchar'; + } + + if ( 'character' === $type ) { + return $length > 0 ? sprintf( 'char(%d)', $length ) : 'char'; + } + + if ( 'bytea' === $type ) { + return 'blob'; + } + + if ( 'integer' === $type ) { + return 'int'; + } + + if ( 'double precision' === $type || 'real' === $type || 'numeric' === $type ) { + return 'float'; + } + + return $type; + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset. + * @return string Collation. + */ + private function get_postgresql_default_collation_for_charset( string $charset ): string { + $charset = strtolower( $charset ); + if ( 'utf8mb3' === $charset ) { + $charset = 'utf8'; + } + + $collations = array( + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + 'latin1' => 'latin1_swedish_ci', + 'big5' => 'big5_chinese_ci', + 'koi8r' => 'koi8r_general_ci', + 'cp1251' => 'cp1251_general_ci', + 'ascii' => 'ascii_general_ci', + ); + + return $collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Get table names from a DROP TABLE query. + * + * @param string $query DROP TABLE query. + * @return string[] Table names. + */ + private function get_postgresql_drop_table_names( string $query ): array { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return array(); + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return array(); + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return array(); + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + $tables = array(); + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $table_name = $this->parse_postgresql_table_reference( $tokens, $position ); + if ( null === $table_name ) { + break; + } + + $tables[] = $table_name; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + break; + } + + ++$position; + } + + return $tables; + } + + /** + * Parse a table reference and return its final identifier. + * + * @param array $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string|null Table identifier, or null when unavailable. + */ + private function parse_postgresql_table_reference( array $tokens, int &$position ): ?string { + $table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + $qualified_table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $qualified_table_name ) { + break; + } + + $table_name = $qualified_table_name; + $position += 2; + } + + return $table_name; + } + + /** + * Get the value from an identifier token. + * + * @param object|null $token MySQL lexer token. + * @return string|null Identifier value, or null when the token is not an identifier. + */ + private function get_postgresql_identifier_token_value( $token ): ?string { + if ( + ! $token + || ! in_array( $token->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + ) { + return null; + } + + return $token->get_value(); + } + + /** + * Normalize a table name for PostgreSQL metadata lookups. + * + * @param string $table Table identifier. + * @return string Table name. + */ + private function normalize_postgresql_table_name( string $table ): string { + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + if ( false !== strpos( $table, '.' ) ) { + $table = substr( $table, strrpos( $table, '.' ) + 1 ); + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + } + + return $table; + } + + /** + * Normalize an identifier for wpdb metadata cache keys. + * + * @param string $identifier Identifier. + * @return string Metadata key. + */ + private function get_postgresql_metadata_key( string $identifier ): string { + return strtolower( $this->normalize_postgresql_table_name( $identifier ) ); + } + + /** + * Checks whether the adapter has a usable PDO-backed PostgreSQL connection. + * + * @return bool Whether a real connection is available. + */ + private function has_usable_postgresql_connection(): bool { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + try { + return $this->dbh->get_connection()->get_pdo() instanceof PDO; + } catch ( Throwable $e ) { + return false; + } + } + + /** + * Changes the current SQL mode. + * + * PostgreSQL does not expose MySQL sql_mode, but WordPress stores and checks + * this state through wpdb. Keep the emulated state on the driver and apply + * the same incompatible-mode filtering as core wpdb. + * + * @param array $modes Optional. A list of SQL modes to set. Default empty array. + */ + public function set_sql_mode( $modes = array() ) { + if ( empty( $modes ) ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return; + } + + $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' ); + if ( ! isset( $result[0] ) ) { + return; + } + + $modes_str = $result[0]->{'@@SESSION.sql_mode'}; + if ( empty( $modes_str ) ) { + return; + } + + $modes = explode( ',', $modes_str ); + } + + $modes = array_map( 'strtoupper', (array) $modes ); + + $incompatible_modes = property_exists( $this, 'incompatible_modes' ) ? (array) $this->incompatible_modes : array(); + if ( function_exists( 'apply_filters' ) ) { + $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $incompatible_modes ); + } + $incompatible_modes = array_map( 'strtoupper', $incompatible_modes ); + + foreach ( $modes as $i => $mode ) { + if ( in_array( $mode, $incompatible_modes, true ) ) { + unset( $modes[ $i ] ); + } + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $this->dbh->set_sql_mode( implode( ',', array_values( $modes ) ) ); + } + } + + /** + * Closes the current database connection. + * + * @return bool True when an open connection existed. + */ + public function close() { + if ( ! $this->dbh ) { + $this->ready = false; + return false; + } + + $this->dbh = null; + $this->ready = false; + return true; + } + + /** + * Method to select the database connection. + * + * @param string $db Database name. + * @param resource|null $dbh Optional link identifier. + * @return bool Whether the selected database matches the configured database. + */ + public function select( $db, $dbh = null ) { + if ( null === $dbh ) { + $dbh = $this->dbh; + } + + $this->ready = $dbh instanceof WP_PostgreSQL_Driver && (string) $db === (string) $this->dbname; + return $this->ready; + } + + /** + * Escapes string data without using mysqli. + * + * @param string $data The string to escape. + * @return string Escaped string. + */ + public function _real_escape( $data ) { + if ( ! is_scalar( $data ) ) { + return ''; + } + + $escaped = addslashes( (string) $data ); + return $this->add_placeholder_escape( $escaped ); + } + + /** + * Prints SQL/DB error. + * + * This mirrors wpdb::print_error() without calling mysqli_error() on the + * PostgreSQL driver object. + * + * @global array $EZSQL_ERROR Stores error information of query and error string. + * + * @param string $str The error to display. + * @return void|false Void if the showing of errors is enabled, false if disabled. + */ + public function print_error( $str = '' ) { + global $EZSQL_ERROR; + + if ( ! $str ) { + $str = $this->last_error; + } + + $EZSQL_ERROR[] = array( + 'query' => $this->last_query, + 'error_str' => $str, + ); + + if ( $this->suppress_errors ) { + return false; + } + + $caller = $this->get_caller(); + if ( $caller ) { + // Not translated, as this will only appear in the error log. + $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller ); + } else { + $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query ); + } + + error_log( $error_str ); + + if ( ! $this->show_errors ) { + return false; + } + + wp_load_translations_early(); + + if ( is_multisite() ) { + $msg = sprintf( + "%s [%s]\n%s\n", + __( 'WordPress database error:' ), + $str, + $this->last_query + ); + + if ( defined( 'ERRORLOGFILE' ) ) { + error_log( $msg, 3, ERRORLOGFILE ); + } + if ( defined( 'DIEONDBERROR' ) ) { + wp_die( $msg ); + } + } else { + $str = htmlspecialchars( $str, ENT_QUOTES ); + $query = htmlspecialchars( $this->last_query, ENT_QUOTES ); + + printf( + '

%s [%s]
%s

', + __( 'WordPress database error:' ), + $str, + $query + ); + } + } + + /** + * Quotes a PostgreSQL identifier. + * + * @param string $identifier Identifier to escape. + * @return string Escaped identifier. + */ + public function quote_identifier( $identifier ) { + return WP_PostgreSQL_Connection::quote_identifier_value( (string) $identifier ); + } + + /** + * Method to flush cached data. + */ + public function flush() { + $this->last_result = array(); + $this->col_info = null; + $this->last_query = null; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->result = null; + } + + /** + * Connects to the PostgreSQL database. + * + * @param bool $allow_bail Whether to bail on connection failure. + * @return bool Whether the connection succeeded. + */ + public function db_connect( $allow_bail = true ) { + $this->is_mysql = true; + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $this->ready = true; + return true; + } + + $this->ready = false; + $this->last_error = ''; + $this->init_charset(); + + if ( null === $this->dbname || '' === (string) $this->dbname ) { + $this->last_error = 'The database name was not set. The PostgreSQL backend requires DB_NAME.'; + if ( $allow_bail ) { + $this->bail( $this->last_error, 'db_connect_fail' ); + } + return false; + } + + try { + $connection = new WP_PostgreSQL_Connection( $this->get_connection_options() ); + $this->dbh = new WP_PostgreSQL_Driver( $connection, $this->dbname ); + $GLOBALS['@pdo'] = $connection->get_pdo(); + $this->ready = true; + $this->set_sql_mode(); + return true; + } catch ( Throwable $e ) { + $this->dbh = null; + $this->ready = false; + $this->last_error = $this->format_error_message( $e ); + + if ( $allow_bail ) { + $this->bail( $this->last_error, 'db_connect_fail' ); + } + + return false; + } + } + + /** + * Method to dummy out wpdb::check_connection(). + * + * @param bool $allow_bail Whether to bail on connection failure. + * @return bool Whether the connection is alive. + */ + public function check_connection( $allow_bail = true ) { + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + try { + $this->dbh->get_connection()->query( 'SELECT 1' ); + return true; + } catch ( Throwable $e ) { + $this->last_error = $this->format_error_message( $e ); + $this->dbh = null; + $this->ready = false; + } + } + + return $this->db_connect( $allow_bail ); + } + + /** + * Prepares a SQL query for safe execution. + * + * @param string $query Query statement with placeholders. + * @param array|mixed $args Variables to substitute. + * @param mixed ...$args Further variables to substitute. + * @return string|void Sanitized query string, if there is a query to prepare. + */ + public function prepare( $query, ...$args ) { + $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' ); + if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) { + $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' ); + $property->setAccessible( true ); + $property->setValue( $this, $this->allow_unsafe_unquoted_parameters ); + $property->setAccessible( false ); + } + + if ( null === $query ) { + return parent::prepare( $query, ...$args ); + } + + $identifier_prepare = $this->prepare_identifier_placeholders( $query, $args ); + if ( null === $identifier_prepare ) { + return parent::prepare( $query, ...$args ); + } + + if ( $identifier_prepare['passed_as_array'] ) { + $prepared = parent::prepare( $identifier_prepare['query'], $identifier_prepare['args'] ); + } else { + $prepared = parent::prepare( $identifier_prepare['query'], ...$identifier_prepare['args'] ); + } + + if ( ! is_string( $prepared ) ) { + return $prepared; + } + + foreach ( $identifier_prepare['identifiers'] as $marker => $identifier ) { + $prepared = str_replace( "'" . $marker . "'", $identifier, $prepared ); + } + + return $prepared; + } + + /** + * Rewrites common unnumbered identifier placeholders for PostgreSQL quoting. + * + * Core wpdb::prepare() hardcodes %i as a MySQL-backticked placeholder. This + * adapter supports the common unnumbered %i form by letting core prepare all + * non-identifier values, then replacing exact quoted marker values with + * PostgreSQL-quoted identifiers. Numbered or formatted identifier placeholders + * fall back to core behavior until they can be mapped safely. + * + * @param string $query Query statement with placeholders. + * @param array $args Variables to substitute. + * @return array|null Rewritten prepare data, or null to use parent behavior. + */ + private function prepare_identifier_placeholders( $query, array $args ) { + if ( ! is_string( $query ) || false === strpos( $query, '%i' ) ) { + return null; + } + + $scan = $this->rewrite_identifier_placeholder_query( $query ); + if ( null === $scan ) { + return null; + } + + $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); + $prepare_args = $passed_as_array ? $args[0] : $args; + $collision_args = $prepare_args; + $identifiers = array(); + static $marker_id = 0; + + foreach ( $scan['identifier_arg_indexes'] as $index => $arg_index ) { + if ( ! array_key_exists( $arg_index, $prepare_args ) ) { + continue; + } + + do { + ++$marker_id; + $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; + } while ( $this->prepare_identifier_marker_has_collision( $marker, $query, $collision_args ) ); + + $identifiers[ $marker ] = $this->quote_identifier( $prepare_args[ $arg_index ] ); + $prepare_args[ $arg_index ] = $marker; + } + + return array( + 'query' => $scan['query'], + 'args' => $prepare_args, + 'identifiers' => $identifiers, + 'passed_as_array' => $passed_as_array, + ); + } + + /** + * Checks whether an internal identifier marker appears in caller-controlled SQL. + * + * @param string $marker Marker candidate. + * @param string $query Query statement with placeholders. + * @param array $args Variables to substitute. + * @return bool Whether the marker collides with the query or arguments. + */ + private function prepare_identifier_marker_has_collision( $marker, $query, array $args ) { + if ( false !== strpos( $query, $marker ) ) { + return true; + } + + foreach ( $args as $arg ) { + if ( ! is_scalar( $arg ) ) { + continue; + } + + if ( false !== strpos( (string) $arg, $marker ) ) { + return true; + } + } + + return false; + } + + /** + * Scans a prepare query and rewrites supported identifier placeholders. + * + * @param string $query Query statement with placeholders. + * @return array|null Rewritten query data, or null when unsupported. + */ + private function rewrite_identifier_placeholder_query( string $query ) { + $length = strlen( $query ); + $position = 0; + $copy_from = 0; + $placeholder_index = 0; + $rewritten = ''; + $has_identifier = false; + $has_numbered = false; + $has_escaped_candidate = false; + $identifier_arg_indexes = array(); + + while ( $position < $length ) { + if ( '%' !== $query[ $position ] ) { + ++$position; + continue; + } + + $run_start = $position; + while ( $position < $length && '%' === $query[ $position ] ) { + ++$position; + } + + $run_length = $position - $run_start; + if ( 0 === $run_length % 2 ) { + continue; + } + + $placeholder_start = $position - 1; + $placeholder = $this->read_prepare_placeholder( $query, $position ); + if ( null === $placeholder ) { + continue; + } + + if ( 1 < $run_length ) { + $has_escaped_candidate = true; + } + if ( $placeholder['numbered'] ) { + $has_numbered = true; + } + + if ( 'i' === $placeholder['type'] ) { + if ( '' !== $placeholder['format'] || 1 < $run_length ) { + return null; + } + + $has_identifier = true; + $identifier_arg_indexes[] = $placeholder_index; + $rewritten .= substr( $query, $copy_from, $placeholder_start - $copy_from ) . '%s'; + $copy_from = $placeholder['end']; + } + + $position = $placeholder['end']; + ++$placeholder_index; + } + + if ( ! $has_identifier || $has_numbered || $has_escaped_candidate ) { + return null; + } + + return array( + 'query' => $rewritten . substr( $query, $copy_from ), + 'identifier_arg_indexes' => $identifier_arg_indexes, + ); + } + + /** + * Reads a wpdb::prepare() placeholder after the opening percent sign. + * + * @param string $query Query statement with placeholders. + * @param int $offset Offset immediately after the opening percent sign. + * @return array|null Placeholder metadata, or null when no placeholder matches. + */ + private function read_prepare_placeholder( string $query, int $offset ) { + $length = strlen( $query ); + $format_start = $offset; + $position = $offset; + $numbered = false; + + if ( $position < $length && '1' <= $query[ $position ] && '9' >= $query[ $position ] ) { + $digits_start = $position; + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '$' === $query[ $position ] ) { + $numbered = true; + ++$position; + } else { + $position = $digits_start; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length ) { + if ( ' ' === $query[ $position ] ) { + ++$position; + } elseif ( "'" === $query[ $position ] && $position + 1 < $length ) { + $position += 2; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '.' === $query[ $position ] ) { + ++$position; + if ( $position >= $length || ! ctype_digit( $query[ $position ] ) ) { + return null; + } + + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + } + + if ( $position >= $length || false === strpos( 'sdfFi', $query[ $position ] ) ) { + return null; + } + + return array( + 'format' => substr( $query, $format_start, $position - $format_start ), + 'type' => $query[ $position ], + 'end' => $position + 1, + 'numbered' => $numbered, + ); + } + + /** + * Checks whether a character is allowed in a wpdb prepare format segment. + * + * @param string $char Character to inspect. + * @return bool Whether the character is a format flag, sign, or width digit. + */ + private function is_prepare_format_flag( string $char ): bool { + return ctype_digit( $char ) || '-' === $char || '+' === $char; + } + + /** + * Performs a database query. + * + * @param string $query Database query. + * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. + * Number of rows affected/selected for all other queries. + * Boolean false on error. + */ + public function query( $query ) { + if ( ! $this->ready ) { + return false; + } + + $query = apply_filters( 'query', $query ); + + if ( ! $query ) { + $this->insert_id = 0; + return false; + } + + $this->flush(); + $this->func_call = "\$db->query(\"$query\")"; + + $check_current_query = true; + if ( property_exists( $this, 'check_current_query' ) ) { + $check_current_query = (bool) $this->check_current_query; + } + + if ( + $check_current_query + && is_string( $query ) + && method_exists( $this, 'check_ascii' ) + && method_exists( $this, 'strip_invalid_text_from_query' ) + && ! $this->check_ascii( $query ) + ) { + $stripped_query = $this->strip_invalid_text_from_query( $query ); + $this->flush(); + if ( $stripped_query !== $query ) { + $this->insert_id = 0; + $this->last_query = $query; + wp_load_translations_early(); + $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' ); + return false; + } + } + + if ( property_exists( $this, 'check_current_query' ) ) { + $this->check_current_query = true; + } + $this->last_query = $query; + + if ( is_string( $query ) && $this->is_empty_where_update_query( $query ) ) { + $this->last_error = 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.'; + return false; + } + + $last_query_count = count( $this->queries ?? array() ); + $this->_do_query( $query ); + + if ( $this->last_error ) { + if ( $this->insert_id && in_array( $this->get_statement_keyword( $query ), array( 'insert', 'replace' ), true ) ) { + $this->insert_id = 0; + } + + $this->print_error(); + return false; + } + + $statement_type = $this->get_statement_keyword( $query ); + if ( 'create' === $statement_type ) { + $this->store_postgresql_create_table_charset_metadata( $query ); + } elseif ( 'drop' === $statement_type ) { + $this->delete_postgresql_dropped_table_charset_metadata( $query ); + } elseif ( 'alter' === $statement_type ) { + $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; + } + + $postgresql_queries = $this->postgresql_query_log_override; + $this->postgresql_query_log_override = null; + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && isset( $this->queries[ $last_query_count ] ) ) { + $this->queries[ $last_query_count ]['postgresql_queries'] = null === $postgresql_queries + ? $this->dbh->get_last_postgresql_queries() + : $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(); + } + + $this->postgresql_query_log_override = null; + + try { + $install_state_result = $this->query_postgresql_missing_options_install_probe( $query ); + $install_state_result = null === $install_state_result + ? $this->query_postgresql_missing_describe_install_probe( $query ) + : $install_state_result; + $site_health_result = null === $install_state_result ? $this->query_postgresql_site_health_table_sizes( $query ) : null; + $this->result = null !== $install_state_result + ? $install_state_result + : ( null === $site_health_result ? $this->dbh->query( $query ) : $site_health_result ); + } 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() + ); + } + } + + /** + * Fast path for WordPress install-state option probes against a missing options table. + * + * @param string $query Original MySQL query. + * @return array|null Empty result rows when the current options table is missing, or null on non-match. + */ + private function query_postgresql_missing_options_install_probe( $query ) { + if ( empty( $this->suppress_errors ) || ! isset( $this->options ) || ! $this->has_usable_postgresql_connection() ) { + return null; + } + + $table = $this->parse_postgresql_options_install_probe_table( $query ); + if ( null === $table || $this->get_postgresql_metadata_key( (string) $this->options ) !== $this->get_postgresql_metadata_key( $table ) ) { + return null; + } + + $exists = $this->postgresql_visible_table_exists( $table ); + if ( null === $exists || $exists ) { + $this->postgresql_query_log_override = null; + return null; + } + + return array(); + } + + /** + * Fast path for WordPress install-state DESCRIBE probes against missing prefixed tables. + * + * @param string $query Original MySQL query. + * @return array|null Empty result rows when the described current-prefix table is missing, or null on non-match. + */ + private function query_postgresql_missing_describe_install_probe( $query ) { + if ( empty( $this->suppress_errors ) || ! isset( $this->prefix ) || ! $this->has_usable_postgresql_connection() ) { + return null; + } + + $table = $this->parse_postgresql_describe_table_probe( $query ); + if ( null === $table || ! $this->is_postgresql_current_prefix_table( $table ) ) { + return null; + } + + $exists = $this->postgresql_visible_table_exists( $table ); + if ( null === $exists || $exists ) { + $this->postgresql_query_log_override = null; + return null; + } + + return array(); + } + + /** + * Parse WordPress' exact current-prefix option install probes. + * + * @param string $query Original MySQL query. + * @return string|null Options table name, or null on non-match. + */ + private function parse_postgresql_options_install_probe_table( $query ) { + if ( ! is_string( $query ) || ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return null; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( + 'option_value' === strtolower( (string) $this->get_postgresql_identifier_token_value( $tokens[1] ?? null ) ) + && WP_MySQL_Lexer::FROM_SYMBOL === ( $tokens[2]->id ?? null ) + ) { + return $this->parse_postgresql_options_value_probe_table( $tokens, 3 ); + } + + return $this->parse_postgresql_options_alloptions_probe_table( $tokens, 5 ); + } + + /** + * Parse WordPress' exact install-state DESCRIBE table probe. + * + * @param string $query Original MySQL query. + * @return string|null Table name, or null on non-match. + */ + private function parse_postgresql_describe_table_probe( $query ) { + if ( ! is_string( $query ) || ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return null; + } + + $tokens = $this->get_postgresql_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; + } + + $table = $this->get_postgresql_identifier_token_value( $tokens[1] ?? null ); + if ( null === $table ) { + return null; + } + return $this->is_postgresql_mysql_token_stream_at_end( $tokens, 2 ) ? $table : null; + } + + /** + * Parse WordPress' exact current-prefix option-value install probe. + * + * @param array $tokens MySQL token stream. + * @param int $position Current token position. + * @return string|null Options table name, or null on non-match. + */ + private function parse_postgresql_options_value_probe_table( array $tokens, int $position ) { + $table = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table ) { + return null; + } + ++$position; + + if ( WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $table = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table ) { + return null; + } + $position += 2; + } + + if ( + WP_MySQL_Lexer::WHERE_SYMBOL !== ( $tokens[ $position ]->id ?? null ) + || 'option_name' !== strtolower( (string) $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== ( $tokens[ $position + 2 ]->id ?? null ) + || WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== ( $tokens[ $position + 3 ]->id ?? null ) + ) { + return null; + } + $position += 4; + + if ( WP_MySQL_Lexer::LIMIT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + if ( + WP_MySQL_Lexer::INT_NUMBER !== ( $tokens[ $position + 1 ]->id ?? null ) + || '1' !== (string) $tokens[ $position + 1 ]->get_value() + ) { + return null; + } + $position += 2; + } + + return $this->is_postgresql_mysql_token_stream_at_end( $tokens, $position ) ? $table : null; + } + + /** + * Parse WordPress' exact current-prefix alloptions install probes. + * + * @param array $tokens MySQL token stream. + * @param int $position Current token position. + * @return string|null Options table name, or null on non-match. + */ + private function parse_postgresql_options_alloptions_probe_table( array $tokens, int $position ) { + if ( + 'option_name' !== strtolower( (string) $this->get_postgresql_identifier_token_value( $tokens[1] ?? null ) ) + || WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[2]->id ?? null ) + || 'option_value' !== strtolower( (string) $this->get_postgresql_identifier_token_value( $tokens[3] ?? null ) ) + || WP_MySQL_Lexer::FROM_SYMBOL !== ( $tokens[4]->id ?? null ) + ) { + return null; + } + + $table = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table ) { + return null; + } + ++$position; + + if ( WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $table = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table ) { + return null; + } + $position += 2; + } + + if ( $this->is_postgresql_mysql_token_stream_at_end( $tokens, $position ) ) { + return $table; + } + + if ( + WP_MySQL_Lexer::WHERE_SYMBOL !== ( $tokens[ $position ]->id ?? null ) + || 'autoload' !== strtolower( (string) $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) ) + || WP_MySQL_Lexer::IN_SYMBOL !== ( $tokens[ $position + 2 ]->id ?? null ) + || ! $this->is_postgresql_alloptions_autoload_literal_list( $tokens, $position + 3 ) + ) { + return null; + } + $position += 12; + + return $this->is_postgresql_mysql_token_stream_at_end( $tokens, $position ) ? $table : null; + } + + /** + * Check whether a token sequence is WordPress' exact alloptions autoload list. + * + * @param array $tokens MySQL token stream. + * @param int $position Opening parenthesis token position. + * @return bool Whether the token sequence matches the alloptions autoload list. + */ + private function is_postgresql_alloptions_autoload_literal_list( array $tokens, int $position ): bool { + $expected_values = array( 'yes', 'on', 'auto-on', 'auto' ); + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + + foreach ( $expected_values as $index => $expected_value ) { + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== ( $tokens[ $position ]->id ?? null ) + || $expected_value !== $tokens[ $position ]->get_value() + ) { + return false; + } + ++$position; + + if ( count( $expected_values ) - 1 === $index ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + } + + return WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ); + } + + /** + * Check whether a token stream is at an optional semicolon and EOF. + * + * @param array $tokens MySQL token stream. + * @param int $position Current token position. + * @return bool Whether the remaining tokens are query terminators only. + */ + private function is_postgresql_mysql_token_stream_at_end( array $tokens, int $position ): bool { + if ( WP_MySQL_Lexer::SEMICOLON_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + + return ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EOF === $tokens[ $position ]->id; + } + + /** + * Check whether a table name belongs to the active WordPress prefix. + * + * @param string $table Table name. + * @return bool Whether the table uses the current prefix. + */ + private function is_postgresql_current_prefix_table( string $table ): bool { + $prefix = (string) $this->prefix; + if ( '' === $prefix ) { + return false; + } + + return 0 === strpos( + $this->get_postgresql_metadata_key( $table ), + $this->get_postgresql_metadata_key( $prefix ) + ); + } + + /** + * Check if a relation is visible on the PostgreSQL search path. + * + * @param string $table Table name. + * @return bool|null Whether the relation exists, or null when the catalog check fails. + */ + private function postgresql_visible_table_exists( string $table ) { + $sql = 'SELECT 1 + FROM pg_catalog.pg_class c + WHERE pg_catalog.pg_table_is_visible(c.oid) + AND lower(c.relname) = lower(?) + AND c.relkind IN (\'r\', \'p\', \'v\', \'m\') + LIMIT 1'; + $params = array( $this->normalize_postgresql_table_name( $table ) ); + + try { + $stmt = $this->dbh->get_connection()->query( $sql, $params ); + } catch ( Throwable $e ) { + return null; + } + + $this->postgresql_query_log_override = array( + array( + 'sql' => $sql, + 'params' => $params, + ), + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Fast path for WordPress Site Health's prepared table-size query. + * + * @param string $query Original MySQL query. + * @return array|null Result rows, or null when the query does not match. + */ + private function query_postgresql_site_health_table_sizes( $query ) { + $details = $this->parse_postgresql_site_health_table_size_query( $query ); + if ( null === $details || ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return null; + } + + $schema = 0 === strcasecmp( $details['schema'], (string) $this->dbname ) ? 'public' : $details['schema']; + $placeholders = implode( ', ', array_fill( 0, count( $details['tables'] ), '?' ) ); + $sql = 'SELECT c.relname AS "table", + CAST(GREATEST(COALESCE(s.n_live_tup, 0), COALESCE(c.reltuples, 0), 0) AS bigint) AS "rows", + CAST(pg_catalog.pg_total_relation_size(c.oid) AS bigint) AS "bytes" + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + LEFT JOIN pg_catalog.pg_stat_all_tables s + ON s.relid = c.oid + WHERE n.nspname = ? + AND c.relname IN (' . $placeholders . ") + AND c.relkind IN ('r', 'p') + ORDER BY c.relname"; + $params = array_merge( array( $schema ), $details['tables'] ); + $stmt = $this->dbh->get_connection()->query( $sql, $params ); + + $this->postgresql_query_log_override = array( + array( + 'sql' => $sql, + 'params' => $params, + ), + ); + + $rows = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_OBJ ) as $row ) { + $rows[] = (object) array( + 'table' => $row->table, + 'rows' => $row->rows, + 'bytes' => $row->bytes, + ); + } + + return $rows; + } + + /** + * Parse WordPress Site Health's exact prepared information_schema.TABLES query. + * + * @param string $query Original MySQL query. + * @return array|null Parsed schema and table names, or null on non-match. + */ + private function parse_postgresql_site_health_table_size_query( $query ) { + if ( ! is_string( $query ) ) { + return null; + } + + $quoted = "'(?:''|[^'])*'"; + if ( ! preg_match( '/\A\s*SELECT\s+TABLE_NAME\s+AS\s+\'table\'\s*,\s*TABLE_ROWS\s+AS\s+\'rows\'\s*,\s*SUM\s*\(\s*data_length\s*\+\s*index_length\s*\)\s+as\s+\'bytes\'\s+FROM\s+information_schema\.TABLES\s+WHERE\s+TABLE_SCHEMA\s*=\s*(?' . $quoted . ')\s+AND\s+TABLE_NAME\s+IN\s*\((?\s*' . $quoted . '(?:\s*,\s*' . $quoted . ')*\s*)\)\s+GROUP\s+BY\s+TABLE_NAME\s*;?\s*\z/i', $query, $matches ) ) { + return null; + } + + preg_match_all( '/' . $quoted . '/', $matches['tables'], $table_matches ); + $tables = array_map( array( $this, 'unquote_postgresql_site_health_literal' ), $table_matches[0] ); + + return empty( $tables ) ? null : array( + 'schema' => $this->unquote_postgresql_site_health_literal( $matches['schema'] ), + 'tables' => $tables, + ); + } + + /** + * Unquote a MySQL single-quoted literal from the exact Site Health query. + * + * @param string $literal Quoted literal. + * @return string Unquoted value. + */ + private function unquote_postgresql_site_health_literal( string $literal ): string { + return str_replace( "''", "'", substr( $literal, 1, -1 ) ); + } + + /** + * 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 ) { + $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; + } + + /** + * Returns the first SQL statement keyword. + * + * @param string $query SQL query. + * @return string Lowercase statement keyword, or empty string. + */ + private function get_statement_keyword( $query ) { + $i = $this->get_statement_start_offset( $query ); + $length = strlen( $query ); + + $start = $i; + while ( $i < $length && ( ctype_alpha( $query[ $i ] ) || '_' === $query[ $i ] ) ) { + ++$i; + } + + return strtolower( substr( $query, $start, $i - $start ) ); + } + + /** + * Check whether an UPDATE statement has no WHERE condition. + * + * @param string $query SQL query. + * @return bool Whether this is an UPDATE ending with an empty WHERE clause. + */ + private function is_empty_where_update_query( string $query ): bool { + if ( 'update' !== $this->get_statement_keyword( $query ) ) { + return false; + } + + $statement = substr( $query, $this->get_statement_start_offset( $query ) ); + + return 1 === preg_match( '/^UPDATE\s+.+\s+WHERE\s*$/is', $statement ); + } + + /** + * Return the offset of the first SQL statement keyword after leading comments. + * + * @param string $query SQL query. + * @return int Statement keyword offset. + */ + private function get_statement_start_offset( string $query ): int { + $length = strlen( $query ); + $i = 0; + + while ( $i < $length ) { + $char = $query[ $i ]; + if ( ctype_space( $char ) ) { + ++$i; + continue; + } + + if ( '-' === $char && $i + 1 < $length && '-' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i < $length && "\n" !== $query[ $i ] ) { + ++$i; + } + continue; + } + + if ( '#' === $char ) { + ++$i; + while ( $i < $length && "\n" !== $query[ $i ] ) { + ++$i; + } + continue; + } + + if ( '/' === $char && $i + 1 < $length && '*' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i + 1 < $length && ! ( '*' === $query[ $i ] && '/' === $query[ $i + 1 ] ) ) { + ++$i; + } + $i += 2; + continue; + } + + break; + } + + return $i; + } + + /** + * Format PostgreSQL driver error message. + * + * @param Throwable $e Error. + * @return string Error message. + */ + private function format_error_message( Throwable $e ) { + return $e->getMessage(); + } +} diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php new file mode 100644 index 000000000..1817c36f8 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php @@ -0,0 +1,55 @@ +%1$s

%2$s

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

%1$s

%2$s

', + 'PDO Driver for PostgreSQL is missing', + 'Your PHP installation appears not to have the PostgreSQL PDO driver loaded. This is required for this version of WordPress and the type of database you have specified.' + ) + ), + 'PDO Driver for PostgreSQL is missing.' + ); +} + +require_once __DIR__ . '/class-wp-postgresql-db.php'; +require_once __DIR__ . '/install-functions.php'; + +$GLOBALS['wpdb'] = new WP_PostgreSQL_DB( + defined( 'DB_USER' ) ? DB_USER : '', + defined( 'DB_PASSWORD' ) ? DB_PASSWORD : '', + defined( 'DB_NAME' ) ? DB_NAME : '', + defined( 'DB_HOST' ) ? DB_HOST : '' +); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php new file mode 100644 index 000000000..4110c5323 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -0,0 +1,195 @@ +dbh instanceof WP_PostgreSQL_Driver + ? $translator->extract_create_table_statements( $schema ) + : $translator->translate_schema( $schema ); + + foreach ( $statements as $statement ) { + $statement_succeeded = false; + + try { + if ( $wpdb->dbh instanceof WP_PostgreSQL_Driver ) { + $wpdb->dbh->query( $statement ); + $statement_succeeded = true; + } else { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Generated from parsed WordPress schema DDL. + $statement_succeeded = false !== $wpdb->query( $statement ); + } + } catch ( Throwable $e ) { + $wpdb->last_error = $e->getMessage(); + } + + if ( ! $statement_succeeded ) { + $message = sprintf( + 'Error occurred while creating PostgreSQL tables or indexes.
Query was: %s
', + var_export( $statement, true ) + ); + $message .= sprintf( 'Error message is: %s', $wpdb->last_error ); + wp_die( $message, 'Database Error!' ); + } + } + + 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/phpcs.xml.dist b/phpcs.xml.dist index c7dd21335..0c86cb336 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -9,7 +9,7 @@ For most standard PHP configurations, this means the memory limit will temporarily be raised. Ref: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Advanced-Usage#specifying-phpini-settings --> - + diff --git a/wp-setup.sh b/wp-setup.sh index 0e4321010..f6efb1027 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -9,10 +9,140 @@ set -e WP_VERSION="6.7.2" +WP_TEST_DB_BACKEND="${WP_TEST_DB_BACKEND:-${1:-sqlite}}" +WP_TEST_SKIP_WORDPRESS_NPM="${WP_TEST_SKIP_WORDPRESS_NPM:-0}" +WP_RELEASE_REPOSITORY_URL="${WP_RELEASE_REPOSITORY_URL:-https://github.com/WordPress/WordPress.git}" -DIR="$(dirname "$0")" +DIR="$(cd "$(dirname "$0")" && pwd)" WP_DIR="$DIR/wordpress" +wp_setup_acquire_lock() { + if wp_setup_try_acquire_lock; then + return + fi + + if wp_setup_try_acquire_recovery_lock || { wp_setup_recover_stale_recovery_lock && wp_setup_try_acquire_recovery_lock; }; then + if wp_setup_recover_stale_empty_lock && wp_setup_try_acquire_lock; then + wp_setup_release_recovery_lock + return + fi + + wp_setup_release_recovery_lock + fi + + wp_setup_report_active_or_ambiguous_lock +} + +wp_setup_try_acquire_lock() { + if mkdir "$WP_SETUP_LOCK_DIR" 2>/dev/null; then + if ! printf '%s\n' "$WP_SETUP_LOCK_OWNER_TOKEN" > "$WP_SETUP_LOCK_OWNER_FILE"; then + echo "Error: Could not write wp-setup.sh lock owner to '$WP_SETUP_LOCK_OWNER_FILE'." >&2 + exit 1 + fi + + trap wp_setup_release_locks EXIT + return 0 + fi + + return 1 +} + +wp_setup_try_acquire_recovery_lock() { + if mkdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null; then + if ! printf '%s\n' "$WP_SETUP_LOCK_OWNER_TOKEN" > "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE"; then + echo "Error: Could not write wp-setup.sh lock recovery owner to '$WP_SETUP_LOCK_RECOVERY_OWNER_FILE'." >&2 + wp_setup_release_recovery_lock + exit 1 + fi + + trap wp_setup_release_locks EXIT + return 0 + fi + + return 1 +} + +wp_setup_recover_stale_recovery_lock() { + local stale_recovery_lock_dir + local unexpected_recovery_lock_entry + + stale_recovery_lock_dir="$(find "$WP_SETUP_LOCK_RECOVERY_DIR" -maxdepth 0 -type d -mmin "$WP_SETUP_LOCK_STALE_AGE_MINUTES" -print -quit 2>/dev/null || true)" + if [ -z "$stale_recovery_lock_dir" ]; then + return 1 + fi + + unexpected_recovery_lock_entry="$(find "$WP_SETUP_LOCK_RECOVERY_DIR" -mindepth 1 -maxdepth 1 ! -name 'owner' -print -quit 2>/dev/null || true)" + if [ -n "$unexpected_recovery_lock_entry" ]; then + return 1 + fi + + if [ -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" ]; then + rm -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" || return 1 + fi + + rmdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null +} + +wp_setup_recover_stale_empty_lock() { + local stale_empty_lock_dir + + stale_empty_lock_dir="$(find "$WP_SETUP_LOCK_DIR" -maxdepth 0 -type d -empty -mmin "$WP_SETUP_LOCK_STALE_AGE_MINUTES" -print -quit 2>/dev/null || true)" + if [ -z "$stale_empty_lock_dir" ]; then + return 1 + fi + + rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null +} + +wp_setup_report_active_or_ambiguous_lock() { + echo 'Error: Another wp-setup.sh process is already running for this checkout.' >&2 + echo "If no setup process is running, remove '$WP_SETUP_LOCK_DIR' and rerun this command." >&2 + exit 1 +} + +wp_setup_release_locks() { + wp_setup_release_lock + wp_setup_release_recovery_lock +} + +wp_setup_release_lock() { + if [ -f "$WP_SETUP_LOCK_OWNER_FILE" ] && [ "$(sed -n '1p' "$WP_SETUP_LOCK_OWNER_FILE" 2>/dev/null || true)" = "$WP_SETUP_LOCK_OWNER_TOKEN" ]; then + rm -f "$WP_SETUP_LOCK_OWNER_FILE" + rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null || true + fi +} + +wp_setup_release_recovery_lock() { + if [ -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" ] && [ "$(sed -n '1p' "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" 2>/dev/null || true)" = "$WP_SETUP_LOCK_OWNER_TOKEN" ]; then + rm -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" + rmdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null || true + fi +} + +case "$WP_TEST_DB_BACKEND" in + mysql) + WP_TEST_DB_BACKEND="mysql" + ;; + sqlite) + WP_TEST_DB_BACKEND="sqlite" + ;; + postgres|pgsql|postgresql) + WP_TEST_DB_BACKEND="postgresql" + ;; + *) + echo "Error: Unsupported WP_TEST_DB_BACKEND: $WP_TEST_DB_BACKEND" >&2 + exit 1 + ;; +esac + +WP_SETUP_LOCK_DIR="$DIR/.wp-setup.lock" +WP_SETUP_LOCK_OWNER_FILE="$WP_SETUP_LOCK_DIR/owner" +WP_SETUP_LOCK_RECOVERY_DIR="$DIR/.wp-setup.lock.recovery" +WP_SETUP_LOCK_RECOVERY_OWNER_FILE="$WP_SETUP_LOCK_RECOVERY_DIR/owner" +WP_SETUP_LOCK_OWNER_TOKEN="wp-setup.sh:$$:$(date +%s):$RANDOM:$RANDOM" +WP_SETUP_LOCK_STALE_AGE_MINUTES="+5" +wp_setup_acquire_lock + # 1. Ensure that Git is installed. echo "Checking if Git is installed..." if ! command -v git &> /dev/null; then @@ -22,15 +152,36 @@ fi # 2. Clone the WordPress repository, if it doesn't exist. echo "Cleaning up the WordPress repository..." +if [ -d "$WP_DIR" ]; then + UNWRITABLE_WORDPRESS_PATH="$(find "$WP_DIR" -type d ! -writable -print -quit 2>/dev/null || true)" + if [ -n "$UNWRITABLE_WORDPRESS_PATH" ]; then + echo "Fixing ownership for Docker-generated WordPress files..." + if command -v docker > /dev/null; then + docker run --rm -v "$WP_DIR":/workspace --user 0:0 alpine:3.20 chown -R "$(id -u):$(id -g)" /workspace || true + fi + + UNWRITABLE_WORDPRESS_PATH="$(find "$WP_DIR" -type d ! -writable -print -quit 2>/dev/null || true)" + if [ -n "$UNWRITABLE_WORDPRESS_PATH" ]; then + echo 'Error: Cannot clean the WordPress repository because it contains non-writable generated files.' >&2 + echo "First non-writable path: $UNWRITABLE_WORDPRESS_PATH" >&2 + echo "Fix ownership or remove '$WP_DIR' with appropriate permissions, then rerun this command." >&2 + exit 1 + fi + fi +fi rm -rf "$WP_DIR" echo "Cloning the WordPress repository..." git clone --depth 1 --branch "$WP_VERSION" https://github.com/WordPress/wordpress-develop.git "$WP_DIR" -# 3. Add "docker-compose.override.yml" to the WordPress repository. -echo "Adding 'docker-compose.override.yml' to the WordPress repository..." -cat << EOF > "$WP_DIR/docker-compose.override.yml" +if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then + # 3. Add "docker-compose.override.yml" to the WordPress repository. + echo "Adding 'docker-compose.override.yml' to the WordPress repository..." + cat << EOF > "$WP_DIR/docker-compose.override.yml" services: wordpress-develop: + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database @@ -38,6 +189,9 @@ services: php: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 image: wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database @@ -45,23 +199,472 @@ services: cli: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 image: wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database +EOF +elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then + # 3. Add "docker-compose.override.yml" to the WordPress repository. + echo "Adding PostgreSQL 'docker-compose.override.yml' to the WordPress repository..." + cat << 'EOF' > "$WP_DIR/tools/local-env/postgres-init.sql" +CREATE DATABASE wordpress_develop_tests; +EOF + cat << 'EOF' > "$WP_DIR/tools/local-env/Dockerfile.postgresql-php" +FROM wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + +USER root + +RUN if command -v git > /dev/null; then \ + git config --system --add safe.directory /var/www \ + || git config --global --add safe.directory /var/www; \ + fi + +RUN if command -v apt-get > /dev/null; then \ + apt-get update \ + && apt-get install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk > /dev/null; then \ + apk add --no-cache postgresql-dev \ + && docker-php-ext-install pdo_pgsql; \ + else \ + echo 'Unsupported PHP base image: cannot install pdo_pgsql.' >&2; \ + exit 1; \ + fi +EOF + cat << 'EOF' > "$WP_DIR/tools/local-env/Dockerfile.postgresql-cli" +FROM wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + +USER root + +RUN if command -v git > /dev/null; then \ + git config --system --add safe.directory /var/www \ + || git config --global --add safe.directory /var/www; \ + fi + +RUN if command -v apt-get > /dev/null; then \ + apt-get update \ + && apt-get install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk > /dev/null; then \ + apk add --no-cache postgresql-dev \ + && docker-php-ext-install pdo_pgsql; \ + else \ + echo 'Unsupported CLI base image: cannot install pdo_pgsql.' >&2; \ + exit 1; \ + fi +EOF + cat << EOF > "$WP_DIR/docker-compose.override.yml" +services: + wordpress-develop: + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + depends_on: + mysql: !reset null + php: + condition: service_started + postgres: + condition: service_healthy + + php: + # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 + image: wordpressdevelop/php-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-php + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + + cli: + # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 + image: wordpressdevelop/cli-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-cli + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + depends_on: + mysql: !reset null + php: + condition: service_started + postgres: + condition: service_healthy + + mysql: !reset null + + postgres: + image: postgres:16-alpine + command: + - postgres + - -c + - fsync=off + - -c + - synchronous_commit=off + - -c + - full_page_writes=off + networks: + - wpdevnet + ports: + - "5432" + environment: + POSTGRES_DB: wordpress_develop + POSTGRES_USER: root + POSTGRES_PASSWORD: password + volumes: + - ./tools/local-env/postgres-init.sql:/docker-entrypoint-initdb.d/postgres-init.sql:ro + - postgres:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U root -d wordpress_develop" ] + timeout: 5s + interval: 5s + retries: 10 + +volumes: + mysql: !reset null + postgres: {} EOF +fi + +if [ "$WP_TEST_DB_BACKEND" != "mysql" ]; then + # 4. Add "db.php" to the "wp-content" directory. + echo "Adding '$WP_TEST_DB_BACKEND' db.php to the 'wp-content' directory..." + rm -f "$WP_DIR"/src/wp-content/db.php + cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#{DATABASE_ENGINE}#$WP_TEST_DB_BACKEND#g" "$WP_DIR"/src/wp-content/db.php + rm -f "$WP_DIR"/src/wp-content/db.php.bak +else + echo "Using WordPress default MySQL test database." + rm -f "$WP_DIR"/src/wp-content/db.php +fi + +if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then + # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. + echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." + sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak +elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then + # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_PostgreSQL_DB. + echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_PostgreSQL_DB..." + sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#require_once ABSPATH . 'wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php';\nclass WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak + + echo "Rewriting WordPress wpdb::prepare identifier expectation tests for PostgreSQL..." + node - "$WP_DIR/tests/phpunit/tests/db.php" << 'NODE' +const fs = require( 'fs' ); + +const file = process.argv[2]; +let contents = fs.readFileSync( file, 'utf8' ); + +const replacements = new Map( [ + [ "'SELECT * FROM `my_table` WHERE `my_field` = 321;'", "'SELECT * FROM \"my_table\" WHERE \"my_field\" = 321;'" ], + [ "'WHERE `evil_``_field` = 321;'", "'WHERE \"evil_`_field\" = 321;'" ], + [ "'WHERE `evil_````````````````_field` = 321;'", "'WHERE \"evil_````````_field\" = 321;'" ], + [ "'WHERE `````evil_field````` = 321;'", "'WHERE \"``evil_field``\" = 321;'" ], + [ "'WHERE `evil\\'field` = 321;'", "'WHERE \"evil\\'field\" = 321;'" ], + [ "'WHERE `evil_\\````_field` = 321;'", "'WHERE \"evil_\\``_field\" = 321;'" ], + [ "\"WHERE `evil_{$placeholder_escape}s_field` = 321;\"", "'WHERE \"evil_%s_field\" = 321;'" ], + [ "'WHERE `value``` = 321;'", "'WHERE \"value`\" = 321;'" ], + [ "'WHERE `` AND evil_value` = 321;'", "'WHERE `\" AND evil_value\" = 321;'" ], + [ "'WHERE `evil_value -- `` = 321;'", "'WHERE \"evil_value -- \"` = 321;'" ], + [ "'WHERE `` AND true -- ``` = 321;'", "'WHERE `\" AND true -- \"`` = 321;'" ], + [ "'WHERE ``` AND true -- `` = 321;'", "'WHERE ``\" AND true -- \"` = 321;'" ], + [ "\"WHERE `field' -- ` LIKE 'field\\' -- ' LIMIT 1\"", "\"WHERE \\\"field' -- \\\" LIKE 'field\\' -- ' LIMIT 1\"" ], +] ); + +for ( const [ from, to ] of replacements ) { + const count = contents.split( from ).length - 1; + if ( 1 !== count ) { + throw new Error( `Expected exactly one Tests_DB PostgreSQL prepare replacement for: ${ from }; found ${ count }.` ); + } + + contents = contents.replace( from, to ); +} + +fs.writeFileSync( file, contents ); +NODE + + echo "Rewriting WordPress local-env install script for PostgreSQL..." + node - "$WP_DIR/tools/local-env/scripts/install.js" << 'NODE' +const fs = require( 'fs' ); + +const file = process.argv[2]; +const replacements = [ + { + from: "local_env_utils.determine_auth_option();", + to: [ + "local_env_utils.determine_auth_option();", + "", + "install_postgresql_test_environment();", + "return;", + ], + }, + { + from: "const { renameSync, readFileSync, writeFileSync } = require( 'fs' );", + to: [ + "const fs = require( 'fs' );", + "const { existsSync, renameSync, readFileSync, writeFileSync } = fs;", + ], + }, + { + from: "wp_cli( 'config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --path=/var/www/src --force' );", + to: [ + "wp_cli( 'config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=postgres --path=/var/www/src --force --skip-check' );", + "wp_cli( 'config set DB_ENGINE postgresql --type=constant' );", + "wp_cli( 'config set DATABASE_ENGINE postgresql --type=constant' );", + ], + }, + { + from: "\t.replace( 'localhost', 'mysql' )", + to: [ + "\t.replace( 'localhost', 'postgres' )", + ], + }, + { + from: "renameSync( 'src/wp-config.php', 'wp-config.php' );", + to: [ + "if ( existsSync( 'src/wp-config.php' ) ) {", + "\trenameSync( 'src/wp-config.php', 'wp-config.php' );", + "}", + "if ( ! existsSync( 'wp-config.php' ) ) {", + "\tthrow new Error( 'wp-config.php was not generated.' );", + "}", + ], + }, + { + from: "\t.concat( \"\\ndefine( 'FS_METHOD', 'direct' );\\n\" );", + to: [ + "\t.concat( \"\\ndefine( 'DB_ENGINE', 'postgresql' );\\n\" )", + "\t.concat( \"define( 'DATABASE_ENGINE', 'postgresql' );\\n\" )", + "\t.concat( \"define( 'FS_METHOD', 'direct' );\\n\" );", + ], + }, + { + from: "\t\twp_cli( 'db reset --yes' );", + to: [ + "\t\t// PostgreSQL databases are created by the compose init SQL.", + ], + }, + { + from: "\t\tconst installCommand = process.env.LOCAL_MULTISITE === 'true' ? 'multisite-install' : 'install';", + to: [ + "\t\t// Skip WP-CLI site installation; the PHPUnit bootstrap owns the test schema.", + ], + }, + { + from: "\t\twp_cli( `core ${ installCommand } --title=\"WordPress Develop\" --admin_user=admin --admin_password=password --admin_email=test@test.com --skip-email --url=http://localhost:${process.env.LOCAL_PORT}` );", + to: [ + "\t\t// The PostgreSQL scaffold cannot use WP-CLI's MySQL-backed install commands.", + ], + }, +]; + +const input = fs.readFileSync( file, 'utf8' ).split( '\n' ); +const containsLines = ( lines, expected ) => { + for ( let index = 0; index <= lines.length - expected.length; index++ ) { + let matches = true; + for ( let offset = 0; offset < expected.length; offset++ ) { + if ( lines[ index + offset ] !== expected[ offset ] ) { + matches = false; + break; + } + } + if ( matches ) { + return true; + } + } + return false; +}; -# 4. Add "db.php" to the "wp-content" directory. -echo "Adding 'db.php' to the 'wp-content' directory..." -rm -f "$WP_DIR"/src/wp-content/db.php -cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php +const found = new Set(); +const output = []; +for ( const line of input ) { + const replacement = replacements.find( candidate => candidate.from === line ); + if ( replacement ) { + found.add( replacement.from ); + output.push( ...replacement.to ); + } else { + output.push( line ); + } +} -# 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. -echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." -sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php +for ( const replacement of replacements ) { + if ( ! found.has( replacement.from ) && ! containsLines( input, replacement.to ) ) { + throw new Error( `Expected line not found in ${ file }: ${ replacement.from }` ); + } +} + +let contents = output.join( '\n' ); +let importerExecReplacementCount = 0; +contents = contents.replace( + /exec -T php (rm -rf \$\{testPluginDirectory\}|git clone https:\/\/github\.com\/WordPress\/wordpress-importer\.git \$\{testPluginDirectory\} --depth=1)/g, + ( match, command ) => { + importerExecReplacementCount++; + return `run --rm --workdir /var/www php ${ command }`; + } +); + +if ( 2 !== importerExecReplacementCount ) { + throw new Error( `Expected to rewrite 2 WordPress Importer docker exec commands in ${ file }, rewrote ${ importerExecReplacementCount }.` ); +} + +contents += ` + +function install_postgresql_test_environment() { + write_postgresql_wp_config(); + write_postgresql_wp_tests_config(); + install_postgresql_wp_importer(); +} + +function write_postgresql_wp_config() { + let config = fs.readFileSync( 'wp-config-sample.php', 'utf8' ); + config = config + .replace( "define( 'DB_NAME', 'database_name_here' );", "define( 'DB_NAME', 'wordpress_develop' );" ) + .replace( "define( 'DB_USER', 'username_here' );", "define( 'DB_USER', 'root' );" ) + .replace( "define( 'DB_PASSWORD', 'password_here' );", "define( 'DB_PASSWORD', 'password' );" ) + .replace( "define( 'DB_HOST', 'localhost' );", "define( 'DB_HOST', 'postgres' );" ) + .replace( + "define( 'WP_DEBUG', false );", + "define( 'WP_DEBUG', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG', 'true' ) + " );" + ) + .replace( + '/* Add any custom values between this line and the "stop editing" line. */', + [ + '/* Add any custom values between this line and the "stop editing" line. */', + '', + "define( 'DB_ENGINE', 'postgresql' );", + "define( 'DATABASE_ENGINE', 'postgresql' );", + "define( 'WP_DEBUG_LOG', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG_LOG', 'true' ) + " );", + "define( 'WP_DEBUG_DISPLAY', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG_DISPLAY', 'true' ) + " );", + "define( 'SCRIPT_DEBUG', " + get_postgresql_raw_constant_value( 'LOCAL_SCRIPT_DEBUG', 'true' ) + " );", + "define( 'WP_ENVIRONMENT_TYPE', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_ENVIRONMENT_TYPE', 'local' ) ) + " );", + "define( 'WP_DEVELOPMENT_MODE', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_DEVELOPMENT_MODE', 'core' ) ) + " );", + ].join( '\\n' ) + ); + + fs.rmSync( 'src/wp-config.php', { force: true } ); + fs.writeFileSync( 'wp-config.php', config ); +} + +function write_postgresql_wp_tests_config() { + const testConfig = fs.readFileSync( 'wp-tests-config-sample.php', 'utf8' ) + .replace( 'youremptytestdbnamehere', 'wordpress_develop_tests' ) + .replace( 'yourusernamehere', 'root' ) + .replace( 'yourpasswordhere', 'password' ) + .replace( 'localhost', 'postgres' ) + .replace( + "'WP_TESTS_DOMAIN', 'example.org'", + "'WP_TESTS_DOMAIN', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_TESTS_DOMAIN', 'example.org' ) ) + ) + .concat( "\\ndefine( 'DB_ENGINE', 'postgresql' );\\n" ) + .concat( "define( 'DATABASE_ENGINE', 'postgresql' );\\n" ) + .concat( "define( 'FS_METHOD', 'direct' );\\n" ); + + fs.writeFileSync( 'wp-tests-config.php', testConfig ); +} + +function install_postgresql_wp_importer() { + const testPluginDirectory = 'tests/phpunit/data/plugins/wordpress-importer'; + if ( fs.existsSync( testPluginDirectory + '/wordpress-importer.php' ) ) { + return; + } + + fs.rmSync( testPluginDirectory, { recursive: true, force: true } ); + execSync( 'git clone https://github.com/WordPress/wordpress-importer.git ' + testPluginDirectory + ' --depth=1', { stdio: 'inherit' } ); +} + +function get_postgresql_env_value( name, defaultValue ) { + return process.env[ name ] || defaultValue; +} + +function get_postgresql_raw_constant_value( name, defaultValue ) { + const value = get_postgresql_env_value( name, defaultValue ); + if ( /^(?:true|false|null|[0-9]+)$/i.test( value ) ) { + return value.toLowerCase(); + } + + throw new Error( \`Unsupported raw constant value for \${ name }: \${ value }\` ); +} + +function quote_postgresql_php_string( value ) { + return "'" + String( value ).replace( /\\\\/g, '\\\\\\\\' ).replace( /'/g, "\\\\'" ) + "'"; +} +`; + +fs.writeFileSync( file, contents ); +NODE +fi + +install_wordpress_release_assets() { + local release_asset_path + local release_dir + release_dir="$(mktemp -d "${TMPDIR:-/tmp}/wordpress-release-assets.XXXXXX")" + + echo "Hydrating WordPress release assets for PostgreSQL PHP tests..." + if ! git clone -c advice.detachedHead=false --depth 1 --filter=blob:none --sparse --single-branch --branch "$WP_VERSION" "$WP_RELEASE_REPOSITORY_URL" "$release_dir"; then + rm -rf "$release_dir" + return 1 + fi + + if ! git -C "$release_dir" sparse-checkout set \ + wp-admin/css \ + wp-admin/js \ + wp-includes/assets \ + wp-includes/blocks \ + wp-includes/css \ + wp-includes/js + then + rm -rf "$release_dir" + return 1 + fi + + for release_asset_path in \ + wp-admin/css \ + wp-admin/js \ + wp-includes/assets \ + wp-includes/blocks \ + wp-includes/css \ + wp-includes/js + do + if [ ! -e "$release_dir/$release_asset_path" ]; then + echo "Error: WordPress release asset path is missing: $release_asset_path" >&2 + rm -rf "$release_dir" + return 1 + fi + + rm -rf "$WP_DIR/src/$release_asset_path" + mkdir -p "$(dirname "$WP_DIR/src/$release_asset_path")" + cp -R "$release_dir/$release_asset_path" "$WP_DIR/src/$release_asset_path" + done + + rm -rf "$release_dir" +} # 6. Install dependencies. -echo "Installing dependencies..." -npm --prefix "$WP_DIR" install -npm --prefix "$WP_DIR" run build:dev +if [ "$WP_TEST_DB_BACKEND" = "postgresql" ] && [ "$WP_TEST_SKIP_WORDPRESS_NPM" = "1" ]; then + echo "Installing WordPress npm dependencies without building assets for PostgreSQL PHP tests..." + npm --prefix "$WP_DIR" install --include=dev --ignore-scripts --no-audit --no-fund + echo "Hydrating WordPress release assets and skipping JavaScript build for PostgreSQL PHP tests..." + install_wordpress_release_assets +else + echo "Installing dependencies..." + npm --prefix "$WP_DIR" install + npm --prefix "$WP_DIR" run build:dev +fi