From 0048b792872779cf533430e91902dccc4e254496 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 16:32:39 +0000 Subject: [PATCH 001/142] Add PostgreSQL backend test scaffolding --- .github/workflows/wp-tests-phpunit-run.js | 298 ++++++++++++++---- .github/workflows/wp-tests-phpunit.yml | 207 +++++++++++- .../constants.php | 20 +- .../db.copy | 21 +- .../wp-includes/db.php | 22 ++ .../postgresql/class-wp-postgresql-db.php | 136 ++++++++ .../wp-includes/postgresql/db.php | 54 ++++ .../postgresql/install-functions.php | 18 ++ wp-setup.sh | 64 +++- 9 files changed, 751 insertions(+), 89 deletions(-) create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/db.php create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ae4f5f6a3..580edb8a2 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -9,14 +9,16 @@ const { 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 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 +68,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,26 +92,27 @@ const expectedFailures = [ 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', ]; -console.log( 'Running WordPress PHPUnit tests with expected failures tracking...' ); +const expectedByBackend = { + mysql: { + errors: [], + failures: [], + }, + sqlite: { + errors: sqliteExpectedErrors, + failures: sqliteExpectedFailures, + }, + postgresql: { + errors: [], + failures: [], + }, +}; + +console.log( `Running WordPress PHPUnit tests with ${ backend } expected-result tracking...` ); if ( requiresNativeParserExtension ) { console.log( 'Native parser extension is required for this PHPUnit run.' ); } -console.log( 'Expected errors:', expectedErrors ); -console.log( 'Expected failures:', expectedFailures ); - -function verifyNativeParserExtension() { - const verifier = path.join( __dirname, '..', '..', 'wordpress', 'native-verify-extension.php' ); - if ( ! fs.existsSync( verifier ) ) { - console.error( `Error: Native parser verifier not found at ${ verifier }.` ); - process.exit( 1 ); - } - - execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); - execSync( - 'cd wordpress && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php', - { stdio: 'inherit' } - ); -} +console.log( 'Expected errors:', expectedByBackend[ backend ].errors ); +console.log( 'Expected failures:', expectedByBackend[ backend ].failures ); try { if ( requiresNativeParserExtension ) { @@ -118,88 +121,243 @@ try { try { execSync( - `composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose`, + '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...' ); + console.log( '\nAll tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { - console.log( '\n⚠️ Some tests errored/failed (expected). Analyzing results...' ); + console.log( '\nSome tests errored/failed. Analyzing results...' ); } - // Read the JUnit XML test output: - const junitOutputFile = path.join( __dirname, '..', '..', 'wordpress', 'phpunit-results.xml' ); + const junitOutputFile = path.join( repositoryRoot, '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 ); } - 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 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 readJunitTestcases( junitOutputFile ) { + const parserPath = require.resolve( 'fast-xml-parser', { + paths: [ + path.join( repositoryRoot, 'wordpress', 'node_modules' ), + repositoryRoot, + ], + } ); + const { XMLParser } = require( parserPath ); + const parser = new XMLParser( { + attributeNamePrefix: '', + ignoreAttributes: false, + isArray: name => [ + 'testsuite', + 'testcase', + 'error', + 'failure', + 'skipped', + 'incomplete', + 'risky', + 'warning', + ].includes( name ), + } ); + const junitXml = fs.readFileSync( junitOutputFile, 'utf8' ); + const parsed = parser.parse( junitXml ); + const testcases = []; + collectTestcases( parsed, testcases, false ); + return testcases.map( normalizeTestcase ); +} + +function collectTestcases( node, testcases, isTestcase ) { + if ( Array.isArray( node ) ) { + node.forEach( child => collectTestcases( child, testcases, isTestcase ) ); + return; + } + + if ( ! node || typeof node !== 'object' ) { + return; + } + + if ( isTestcase ) { + testcases.push( node ); + return; + } + + if ( node.testcase ) { + collectTestcases( node.testcase, testcases, true ); + } + + if ( node.testsuite ) { + collectTestcases( node.testsuite, testcases, false ); + } + + if ( node.testsuites ) { + collectTestcases( node.testsuites, testcases, false ); + } +} + +function normalizeTestcase( testcase ) { + const className = testcase.class || ''; + const testName = testcase.name || ''; + const fullName = className ? `${ className }::${ testName }` : testName; + + return { + name: fullName, + hasError: hasChild( testcase, 'error' ), + hasFailure: hasChild( testcase, 'failure' ), + hasSkipped: hasChild( testcase, 'skipped' ), + hasIncomplete: hasChild( testcase, 'incomplete' ), + hasRisky: hasChild( testcase, 'risky' ), + hasWarning: hasChild( testcase, 'warning' ), + }; +} + +function hasChild( testcase, childName ) { + return Array.isArray( testcase[ childName ] ) && testcase[ childName ].length > 0; +} + +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, + total: 0, + passed: 0, + errors: 0, + failures: 0, + skipped: 0, + incomplete: 0, + risky: 0, + warnings: 0, + }; +} + +function writeResultSummary( summary ) { + const outputPath = path.join( repositoryRoot, `wp-phpunit-results-${ backend }.json` ); + 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` ); + } +} diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 810b77b8a..d505e06dc 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -11,8 +11,8 @@ on: permissions: {} jobs: - test: - name: WordPress PHPUnit Tests + mysql-baseline: + name: WordPress PHPUnit Tests / MySQL baseline runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -31,12 +31,204 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - name: Run WordPress PHPUnit tests + env: + WP_TEST_DB_BACKEND: mysql + run: node .github/workflows/wp-tests-phpunit-run.js + + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-mysql + path: wp-phpunit-results-mysql.json + if-no-files-found: warn + + - name: Stop Docker containers + if: always() + run: composer run wp-test-clean + + sqlite-test: + name: WordPress PHPUnit Tests / SQLite + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up 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: 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: 20 + 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() run: composer run wp-test-clean + update-pr-description: + name: Update PR PHPUnit Progress + needs: + - mysql-baseline + - 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: + pattern: wp-phpunit-* + path: wp-phpunit-artifacts + + - 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 progress = renderProgressBar( postgresql.passed, sqlite.passed ); + const generated = [ + startMarker, + `WordPress PHPUnit: SQLite ${ formatNumber( sqlite.passed ) } passed; PostgreSQL ${ formatNumber( postgresql.passed ) } passed`, + `\`${ progress }\``, + endMarker, + ].join( '\n' ); + + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const pull = await github.rest.pulls.get( { owner, repo, pull_number } ); + 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 +249,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 +259,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/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 15e6772a1..1f3471186 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( 'sqlite_database_integration_normalize_db_engine' ) ) { + /** + * Normalizes supported database engine names. + * + * @param string $engine Database engine name. + * @return string Canonical database engine name. + */ + function 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', 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..210ddd9f0 100644 --- a/packages/plugin-sqlite-database-integration/db.copy +++ b/packages/plugin-sqlite-database-integration/db.copy @@ -24,17 +24,30 @@ 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}' ); +if ( '{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..412d7cbf1 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/db.php @@ -0,0 +1,22 @@ +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 ) {} + + /** + * 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 ) { + return 'utf8mb4'; + } + + /** + * Connects to the PostgreSQL database. + * + * @param bool $allow_bail Not used. + * @return false + */ + public function db_connect( $allow_bail = true ) { + $this->ready = false; + $this->last_error = 'The PostgreSQL backend is selected, but the PostgreSQL MySQL-emulation driver has not been implemented yet.'; + return false; + } + + /** + * Method to select the database connection. + * + * @param string $db Database name. + * @param resource|null $dbh Optional link identifier. + */ + public function select( $db, $dbh = null ) { + $this->ready = false; + } + + /** + * Method to dummy out wpdb::check_connection(). + * + * @param bool $allow_bail Not used. + * @return bool + */ + public function check_connection( $allow_bail = true ) { + return false; + } + + /** + * 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 ); + } + + return parent::prepare( $query, ...$args ); + } + + /** + * 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 ) { + return 'subqueries' === strtolower( $db_cap ); + } + + /** + * 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() { + return 'PostgreSQL backend pending implementation'; + } +} 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..e11d13b51 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php @@ -0,0 +1,54 @@ +%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..f8d67822f --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -0,0 +1,18 @@ +&2 + exit 1 + ;; +esac + # 1. Ensure that Git is installed. echo "Checking if Git is installed..." if ! command -v git &> /dev/null; then @@ -26,11 +43,15 @@ 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" != "mysql" ]; 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 +59,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,21 +69,37 @@ 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 +fi -# 4. Add "db.php" to the "wp-content" directory. -echo "Adding 'db.php' to the 'wp-content' directory..." -rm -f "$WP_DIR"/src/wp-content/db.php -cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php +if [ "$WP_TEST_DB_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 +else + echo "Using WordPress default MySQL test database." + rm -f "$WP_DIR"/src/wp-content/db.php +fi -# 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. -echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." -sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php +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 +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 {#class WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php +fi # 6. Install dependencies. echo "Installing dependencies..." From 0621ab70825733cc6378cd2bcf031548203dfab8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 16:40:10 +0000 Subject: [PATCH 002/142] Add PostgreSQL connection wrapper --- .github/workflows/wp-tests-phpunit.yml | 37 ---- packages/mysql-on-sqlite/src/load.php | 1 + .../class-wp-postgresql-connection.php | 206 ++++++++++++++++++ .../tests/WP_PostgreSQL_Connection_Tests.php | 55 +++++ wp-setup.sh | 2 +- 5 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index d505e06dc..78188bc05 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -11,42 +11,6 @@ on: permissions: {} jobs: - mysql-baseline: - name: WordPress PHPUnit Tests / MySQL baseline - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read # Required to clone the repo. - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up 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: mysql - run: node .github/workflows/wp-tests-phpunit-run.js - - - name: Upload PHPUnit count - if: always() - uses: actions/upload-artifact@v4 - with: - name: wp-phpunit-mysql - path: wp-phpunit-results-mysql.json - if-no-files-found: warn - - - name: Stop Docker containers - if: always() - run: composer run wp-test-clean - sqlite-test: name: WordPress PHPUnit Tests / SQLite runs-on: ubuntu-latest @@ -122,7 +86,6 @@ jobs: update-pr-description: name: Update PR PHPUnit Progress needs: - - mysql-baseline - sqlite-test - postgresql-test if: github.event_name == 'pull_request' && always() diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 62387a2e7..ee2751e34 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -43,3 +43,4 @@ 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'; diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php new file mode 100644 index 000000000..9c0a2f6c7 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -0,0 +1,206 @@ +pdo = $options['pdo']; + } else { + $dsn = isset( $options['dsn'] ) ? (string) $options['dsn'] : self::build_dsn( $options ); + $user = isset( $options['user'] ) ? (string) $options['user'] : null; + $password = isset( $options['password'] ) ? (string) $options['password'] : null; + $pdo_class = PHP_VERSION_ID >= 80400 && class_exists( 'PDO\Pgsql' ) ? PDO\Pgsql::class : PDO::class; + + $this->pdo = new $pdo_class( $dsn, $user, $password ); + } + + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Builds a PostgreSQL PDO DSN from connection options. + * + * @param array $options Connection options. + * @return string PostgreSQL PDO DSN. + * + * @throws InvalidArgumentException When no DSN or dbname option is provided. + */ + public static function build_dsn( array $options ): string { + if ( ! isset( $options['dbname'] ) || '' === (string) $options['dbname'] ) { + throw new InvalidArgumentException( 'Option "dbname" is required when "dsn" or "pdo" is not provided.' ); + } + + $parts = array(); + foreach ( array( 'host', 'port', 'dbname' ) as $key ) { + if ( isset( $options[ $key ] ) && '' !== (string) $options[ $key ] ) { + $parts[] = $key . '=' . self::format_dsn_value( (string) $options[ $key ] ); + } + } + + return 'pgsql:' . implode( ';', $parts ); + } + + /** + * Quote a PostgreSQL identifier value. + * + * @param string $unquoted_identifier The unquoted identifier value. + * @return string The quoted identifier value. + * + * @throws InvalidArgumentException When the identifier contains a NUL byte. + */ + public static function quote_identifier_value( string $unquoted_identifier ): string { + if ( false !== strpos( $unquoted_identifier, "\0" ) ) { + throw new InvalidArgumentException( 'PostgreSQL identifiers cannot contain NUL bytes.' ); + } + + return '"' . str_replace( '"', '""', $unquoted_identifier ) . '"'; + } + + /** + * Execute a query in PostgreSQL. + * + * @param string $sql The query to execute. + * @param array $params The query parameters. + * @return PDOStatement The PDO statement object. + * + * @throws PDOException When the query execution fails. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + $stmt = $this->pdo->prepare( $sql ); + $stmt->execute( $params ); + return $stmt; + } + + /** + * 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() ); + } + 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 { + 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; + } + + /** + * 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; + } +} 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..bedf70614 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -0,0 +1,55 @@ +assertSame( + 'pgsql:host=localhost;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'localhost', + '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 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" ); + } +} diff --git a/wp-setup.sh b/wp-setup.sh index c2b177f06..b7a2bc224 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -98,7 +98,7 @@ if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then 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 {#class WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + 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 fi # 6. Install dependencies. From e5f44814b02d629d75ec42d118fdb92314a2c575 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 16:55:44 +0000 Subject: [PATCH 003/142] Avoid PR progress update failure on forks --- .github/workflows/wp-tests-phpunit.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 78188bc05..33ad9dc05 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -172,6 +172,19 @@ jobs: 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 } ); const body = pull.data.body || ''; const startIndex = body.indexOf( startMarker ); From 805ac1c1a9370ca92cab0db376c11eb08aeb1b6d Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:07:19 +0000 Subject: [PATCH 004/142] Add PostgreSQL driver scaffold --- .github/workflows/wp-tests-phpunit.yml | 10 + packages/mysql-on-sqlite/src/load.php | 1 + .../postgresql/class-wp-postgresql-driver.php | 440 +++++++++++++++ .../tests/WP_PostgreSQL_Connection_Tests.php | 153 +++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 89 +++ .../constants.php | 6 +- .../db.copy | 3 +- .../wp-includes/db.php | 2 +- .../postgresql/class-wp-postgresql-db.php | 527 +++++++++++++++++- .../wp-includes/postgresql/db.php | 3 +- 10 files changed, 1215 insertions(+), 19 deletions(-) create mode 100644 packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 33ad9dc05..d1cf34079 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -6,6 +6,10 @@ on: - main pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. permissions: {} @@ -51,6 +55,7 @@ jobs: name: WordPress PHPUnit Tests / PostgreSQL runs-on: ubuntu-latest timeout-minutes: 20 + continue-on-error: true permissions: contents: read # Required to clone the repo. @@ -186,6 +191,11 @@ jobs: } 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 ); diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index ee2751e34..71ad193fa 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -44,3 +44,4 @@ 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-driver.php'; 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..e28197e63 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -0,0 +1,440 @@ +connection = $connection; + $this->db_name = $database; + $this->client_info = $this->read_server_version(); + + $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Get the PostgreSQL connection instance. + * + * @return WP_PostgreSQL_Connection + */ + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + /** + * Get the PostgreSQL server version. + * + * @return string + */ + public function get_postgresql_version(): string { + return $this->client_info; + } + + /** + * Get the last executed MySQL query. + * + * @return string|null + */ + public function get_last_mysql_query(): ?string { + return $this->last_mysql_query; + } + + /** + * Get backend queries executed for the last MySQL query. + * + * @return array + */ + public function get_last_postgresql_queries(): array { + return $this->last_postgresql_queries; + } + + /** + * Get the auto-increment value generated for the last query. + * + * @return int|string + */ + public function get_insert_id() { + try { + $insert_id = $this->connection->get_last_insert_id(); + } catch ( Throwable $e ) { + return 0; + } + + return is_numeric( $insert_id ) ? (int) $insert_id : $insert_id; + } + + /** + * Execute a query. + * + * @param string $query Full SQL statement string. + * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Return value, depending on the query type. + */ + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->reset_query_state(); + $this->last_mysql_query = $query; + + $stmt = $this->connection->query( $query ); + $this->last_postgresql_queries[] = array( + 'sql' => $query, + 'params' => array(), + ); + + if ( $stmt->columnCount() > 0 ) { + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + } else { + $this->last_column_meta = array(); + $this->last_result = $stmt->rowCount(); + } + + return $this->last_result; + } + + /** + * Get results of the last query. + * + * @return mixed + */ + public function get_query_results() { + return $this->last_result; + } + + /** + * Get return value of the last query() function call. + * + * @return mixed + */ + public function get_last_return_value() { + return $this->last_result; + } + + /** + * Get the number of columns returned by the last query. + * + * @return int + */ + public function get_last_column_count(): int { + return count( $this->last_column_meta ); + } + + /** + * Get column metadata for results of the last query. + * + * @return array + */ + public function get_last_column_meta(): array { + return $this->last_column_meta; + } + + /** + * Begin a transaction. + */ + public function beginTransaction(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->connection->get_pdo()->beginTransaction(); + } + + /** + * A temporary alias for back compatibility. + * + * @see self::beginTransaction() + */ + public function begin_transaction(): void { + $this->beginTransaction(); + } + + /** + * Commit the current transaction. + */ + public function commit(): void { + $this->connection->get_pdo()->commit(); + } + + /** + * Rollback the current transaction. + */ + public function rollback(): void { + $this->connection->get_pdo()->rollBack(); + } + + /** + * Reset per-query state. + */ + private function reset_query_state(): void { + $this->last_result = null; + $this->last_column_meta = array(); + $this->last_mysql_query = null; + $this->last_postgresql_queries = array(); + } + + /** + * Read the backend server version without requiring a PostgreSQL-only query. + * + * @return string + */ + private function read_server_version(): string { + try { + $version = $this->connection->get_pdo()->getAttribute( PDO::ATTR_SERVER_VERSION ); + if ( false !== $version && null !== $version ) { + return (string) $version; + } + } catch ( Throwable $e ) { + return 'PostgreSQL'; + } + + return 'PostgreSQL'; + } + + /** + * Normalize PDO column metadata into the MySQLi-shaped fields wpdb expects. + * + * @param PDOStatement $stmt The statement to inspect. + * @return array + */ + private function normalize_column_meta( PDOStatement $stmt ): array { + $meta = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + $column_meta = $stmt->getColumnMeta( $i ); + if ( ! is_array( $column_meta ) ) { + $column_meta = array(); + } + $meta[] = $this->normalize_single_column_meta( $column_meta ); + } + return $meta; + } + + /** + * Normalize metadata for one column. + * + * @param array $column_meta Raw PDO column metadata. + * @return array + */ + private function normalize_single_column_meta( array $column_meta ): array { + $name = isset( $column_meta['name'] ) ? (string) $column_meta['name'] : ''; + $table = isset( $column_meta['table'] ) ? (string) $column_meta['table'] : ''; + $native_type = isset( $column_meta['native_type'] ) ? strtolower( (string) $column_meta['native_type'] ) : ''; + $type = $this->map_native_type( $native_type ); + $length = isset( $column_meta['len'] ) ? (int) $column_meta['len'] : 0; + $precision = isset( $column_meta['precision'] ) ? (int) $column_meta['precision'] : 0; + + return array( + 'native_type' => $type['native_type'], + 'pdo_type' => $type['pdo_type'], + 'flags' => isset( $column_meta['flags'] ) && is_array( $column_meta['flags'] ) ? $column_meta['flags'] : array(), + 'table' => $table, + 'name' => $name, + 'len' => $length, + 'precision' => $precision, + 'mysqli:orgname' => $name, + 'mysqli:orgtable' => $table, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => $type['charsetnr'], + 'mysqli:flags' => 0, + 'mysqli:type' => $type['mysqli_type'], + ); + } + + /** + * Map PostgreSQL native type names to conservative MySQL/PDO metadata. + * + * @param string $native_type Lowercase PDO native type. + * @return array + */ + private function map_native_type( string $native_type ): array { + $defaults = array( + 'native_type' => 'VAR_STRING', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 253, + 'charsetnr' => 255, + ); + + $map = array( + 'int2' => array( + 'native_type' => 'SHORT', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 2, + 'charsetnr' => 63, + ), + 'smallint' => array( + 'native_type' => 'SHORT', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 2, + 'charsetnr' => 63, + ), + 'int4' => array( + 'native_type' => 'LONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 3, + 'charsetnr' => 63, + ), + 'integer' => array( + 'native_type' => 'LONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 3, + 'charsetnr' => 63, + ), + 'int8' => array( + 'native_type' => 'LONGLONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 8, + 'charsetnr' => 63, + ), + 'bigint' => array( + 'native_type' => 'LONGLONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 8, + 'charsetnr' => 63, + ), + 'bytea' => array( + 'native_type' => 'BLOB', + 'pdo_type' => PDO::PARAM_LOB, + 'mysqli_type' => 252, + 'charsetnr' => 63, + ), + 'blob' => array( + 'native_type' => 'BLOB', + 'pdo_type' => PDO::PARAM_LOB, + 'mysqli_type' => 252, + 'charsetnr' => 63, + ), + 'bool' => array( + 'native_type' => 'TINY', + 'pdo_type' => PDO::PARAM_BOOL, + 'mysqli_type' => 1, + 'charsetnr' => 63, + ), + 'boolean' => array( + 'native_type' => 'TINY', + 'pdo_type' => PDO::PARAM_BOOL, + 'mysqli_type' => 1, + 'charsetnr' => 63, + ), + 'numeric' => array( + 'native_type' => 'NEWDECIMAL', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 246, + 'charsetnr' => 63, + ), + 'decimal' => array( + 'native_type' => 'NEWDECIMAL', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 246, + 'charsetnr' => 63, + ), + 'float4' => array( + 'native_type' => 'FLOAT', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 4, + 'charsetnr' => 63, + ), + 'float8' => array( + 'native_type' => 'DOUBLE', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 5, + 'charsetnr' => 63, + ), + 'date' => array( + 'native_type' => 'DATE', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 10, + 'charsetnr' => 63, + ), + 'time' => array( + 'native_type' => 'TIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 11, + 'charsetnr' => 63, + ), + 'timestamp' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + 'timestamptz' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + 'datetime' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + ); + + return isset( $map[ $native_type ] ) ? $map[ $native_type ] : $defaults; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index bedf70614..b08debe5c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -22,6 +22,47 @@ public function test_build_dsn_from_structured_options(): void { ); } + /** + * Tests PostgreSQL DSN construction requires a database name. + * + * @dataProvider data_missing_dbname_options + * + * @param array $options Connection options. + */ + public function test_build_dsn_requires_non_empty_dbname( array $options ): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( $options ); + } + + /** + * Data provider for missing database-name options. + * + * @return array + */ + public function data_missing_dbname_options(): array { + return array( + 'not set' => array( array() ), + 'empty' => array( array( 'dbname' => '' ) ), + 'null' => array( array( 'dbname' => null ) ), + ); + } + + /** + * Tests empty host and port values are omitted. + */ + public function test_build_dsn_omits_empty_host_and_port(): void { + $this->assertSame( + 'pgsql:dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '', + 'port' => '', + 'dbname' => 'wp', + ) + ) + ); + } + /** * Tests PostgreSQL DSN separator rejection. */ @@ -35,6 +76,45 @@ public function test_build_dsn_rejects_structured_option_separators(): void { ); } + /** + * 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. */ @@ -52,4 +132,77 @@ 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_expected_attributes(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $this->assertSame( $pdo, $connection->get_pdo() ); + $this->assertSame( PDO::ERRMODE_EXCEPTION, $pdo->getAttribute( PDO::ATTR_ERRMODE ) ); + $this->assertTrue( $pdo->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ); + } + + /** + * Tests query execution with parameters and query logging. + */ + public function test_query_executes_parameters_and_logs_query(): void { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + $log = array(); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$log ): void { + $log[] = array( $sql, $params ); + } + ); + + $stmt = $connection->query( 'SELECT ? AS value', array( 'ok' ) ); + + $this->assertSame( array( 'value' => 'ok' ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( array( array( 'SELECT ? AS value', array( 'ok' ) ) ), $log ); + } + + /** + * Tests prepare returns a PDO statement and logs without parameters. + */ + public function test_prepare_returns_statement_and_logs_without_params(): void { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + $log = array(); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$log ): void { + $log[] = array( $sql, $params ); + } + ); + + $stmt = $connection->prepare( 'SELECT ? AS value' ); + $stmt->execute( array( 'ok' ) ); + + $this->assertInstanceOf( PDOStatement::class, $stmt ); + $this->assertSame( array( 'value' => 'ok' ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( array( array( 'SELECT ? AS value', array() ) ), $log ); + } + + /** + * Tests last insert ID delegates to the injected PDO. + */ + public function test_get_last_insert_id_delegates_to_injected_pdo_default_sequence(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $pdo->exec( "INSERT INTO t (value) VALUES ('first')" ); + + $this->assertSame( '1', $connection->get_last_insert_id() ); + } + + /** + * Tests value quoting delegates to the injected PDO. + */ + public function test_quote_delegates_to_injected_pdo(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $this->assertSame( $pdo->quote( "O'Reilly" ), $connection->quote( "O'Reilly" ) ); + } } 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..bad0c9718 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -0,0 +1,89 @@ +create_driver(); + + $rows = $driver->query( "SELECT 1 AS id, 'ok' AS value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'ok', $rows[0]->value ); + $this->assertSame( 'SELECT 1 AS id, \'ok\' AS value', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => "SELECT 1 AS id, 'ok' AS value", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $column_meta = $driver->get_last_column_meta(); + $this->assertCount( 2, $column_meta ); + $this->assertSame( 'id', $column_meta[0]['name'] ); + $this->assertSame( 'wptests', $column_meta[0]['mysqli:db'] ); + $this->assertArrayHasKey( 'mysqli:type', $column_meta[0] ); + $this->assertArrayHasKey( 'mysqli:charsetnr', $column_meta[0] ); + } + + /** + * Tests write queries return PDO row counts. + */ + public function test_write_query_returns_row_count(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $result = $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + + $this->assertSame( 1, $result ); + $this->assertSame( 1, $driver->get_last_return_value() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests insert IDs are cast to integers when numeric. + */ + public function test_get_insert_id_casts_numeric_strings(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + + $this->assertSame( 1, $driver->get_insert_id() ); + } + + /** + * Tests transaction methods delegate to PDO. + */ + public function test_transaction_methods_delegate_to_pdo(): void { + $driver = $this->create_driver(); + + $driver->beginTransaction(); + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->rollback(); + + $stmt = $driver->get_connection()->query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 't'" ); + $this->assertFalse( $stmt->fetchColumn() ); + } + + /** + * Creates a PostgreSQL driver backed by an injected in-memory PDO. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } +} diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 1f3471186..18d5eaed1 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -6,14 +6,14 @@ * @package wp-sqlite-integration */ -if ( ! function_exists( 'sqlite_database_integration_normalize_db_engine' ) ) { +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 sqlite_database_integration_normalize_db_engine( $engine ) { + function wp_sqlite_database_integration_normalize_db_engine( $engine ) { $engine = strtolower( (string) $engine ); if ( in_array( $engine, array( 'postgres', 'pgsql', 'postgresql' ), true ) ) { @@ -30,7 +30,7 @@ function sqlite_database_integration_normalize_db_engine( $engine ) { define( 'DB_ENGINE', 'sqlite' ); } elseif ( defined( 'DATABASE_ENGINE' ) ) { // backwards compatibility with previous versions of the plugin. - define( 'DB_ENGINE', sqlite_database_integration_normalize_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 210ddd9f0..9c83e7213 100644 --- a/packages/plugin-sqlite-database-integration/db.copy +++ b/packages/plugin-sqlite-database-integration/db.copy @@ -29,7 +29,8 @@ if ( ! $sqlite_plugin_implementation_folder_path || ! file_exists( $sqlite_plugi $database_engine = defined( 'DB_ENGINE' ) ? DB_ENGINE : ( defined( 'DATABASE_ENGINE' ) ? DATABASE_ENGINE : '{DATABASE_ENGINE}' ); -if ( '{DATABASE_ENGINE}' === $database_engine ) { +$unreplaced_database_engine = '{' . 'DATABASE_ENGINE' . '}'; +if ( $unreplaced_database_engine === $database_engine ) { $database_engine = 'sqlite'; } $database_engine = strtolower( (string) $database_engine ); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/db.php b/packages/plugin-sqlite-database-integration/wp-includes/db.php index 412d7cbf1..a39c3de15 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/db.php @@ -8,7 +8,7 @@ require_once __DIR__ . '/../constants.php'; $database_engine = defined( 'DB_ENGINE' ) - ? sqlite_database_integration_normalize_db_engine( DB_ENGINE ) + ? wp_sqlite_database_integration_normalize_db_engine( DB_ENGINE ) : 'mysql'; if ( 'sqlite' === $database_engine ) { diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 8761843ca..8f7274f25 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -5,6 +5,17 @@ * @package wp-sqlite-integration */ +if ( ! class_exists( 'WP_PostgreSQL_Driver', false ) ) { + require_once __DIR__ . '/../database/postgresql/class-wp-postgresql-connection.php'; + require_once __DIR__ . '/../database/postgresql/class-wp-postgresql-driver.php'; +} + +/* + * The PostgreSQL drop-in uses PDO through the backend driver. Enable PDO + * type checks in this compatibility layer: + * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO + */ + /** * PostgreSQL-backed wpdb replacement. * @@ -12,6 +23,13 @@ * of reusing the SQLite file-backed connection path. */ class WP_PostgreSQL_DB extends wpdb { + /** + * Database handle. + * + * @var WP_PostgreSQL_Driver|null + */ + protected $dbh; + /** * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. * @@ -55,15 +73,28 @@ public function get_col_charset( $table, $column ) { } /** - * Connects to the PostgreSQL database. + * Changes the current SQL mode. * - * @param bool $allow_bail Not used. - * @return false + * PostgreSQL does not expose MySQL sql_mode. The MySQL-emulation layer will + * own this state once query translation is implemented. + * + * @param array $modes Optional. A list of SQL modes to set. Default empty array. */ - public function db_connect( $allow_bail = true ) { - $this->ready = false; - $this->last_error = 'The PostgreSQL backend is selected, but the PostgreSQL MySQL-emulation driver has not been implemented yet.'; - return false; + public function set_sql_mode( $modes = array() ) {} + + /** + * Closes the current database connection. + * + * @return bool True when an open connection existed. + */ + public function close() { + if ( ! $this->dbh ) { + return false; + } + + $this->dbh = null; + $this->ready = false; + return true; } /** @@ -71,19 +102,127 @@ public function db_connect( $allow_bail = true ) { * * @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 ) { - $this->ready = false; + 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 ); + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $quoted = $this->dbh->get_connection()->quote( (string) $data ); + if ( false !== $quoted && 2 <= strlen( $quoted ) && "'" === $quoted[0] && "'" === substr( $quoted, -1 ) ) { + $escaped = substr( $quoted, 1, -1 ); + } + } + + return $this->add_placeholder_escape( $escaped ); + } + + /** + * 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 = false; + + 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 Not used. - * @return bool + * @param bool $allow_bail Whether to bail on connection failure. + * @return bool Whether the connection is alive. */ public function check_connection( $allow_bail = true ) { - return false; + 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 ); } /** @@ -103,9 +242,84 @@ public function prepare( $query, ...$args ) { $property->setAccessible( false ); } + if ( null === $query ) { + return parent::prepare( $query, ...$args ); + } + + $prepared = $this->prepare_postgresql_identifiers( (string) $query, $args ); + if ( $prepared['changed'] ) { + return parent::prepare( $prepared['query'], $prepared['args'] ); + } + return parent::prepare( $query, ...$args ); } + /** + * 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\")"; + $this->last_query = $query; + + $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 ( in_array( $statement_type, array( 'create', 'alter', 'truncate', 'drop' ), true ) ) { + $return_val = true; + } elseif ( in_array( $statement_type, array( 'insert', 'delete', 'update', 'replace' ), true ) ) { + $this->rows_affected = $this->dbh->get_last_return_value(); + + if ( in_array( $statement_type, array( 'insert', 'replace' ), true ) ) { + $this->insert_id = $this->dbh->get_insert_id(); + } + + $return_val = $this->rows_affected; + } else { + $num_rows = 0; + + if ( is_array( $this->result ) ) { + $this->last_result = $this->result; + $num_rows = count( $this->result ); + } + + $this->num_rows = $num_rows; + $return_val = $num_rows; + } + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && isset( $this->queries[ $last_query_count ] ) ) { + $this->queries[ $last_query_count ]['postgresql_queries'] = $this->dbh->get_last_postgresql_queries(); + } + + return $return_val; + } + /** * Method to return what the database can do. * @@ -113,7 +327,7 @@ public function prepare( $query, ...$args ) { * @return bool Whether the database feature is supported. */ public function has_cap( $db_cap ) { - return 'subqueries' === strtolower( $db_cap ); + return in_array( strtolower( $db_cap ), array( 'identifier_placeholders', 'subqueries' ), true ); } /** @@ -131,6 +345,293 @@ public function db_version() { * @return string Server info. */ public function db_server_info() { - return 'PostgreSQL backend pending implementation'; + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + return $this->dbh->get_postgresql_version(); + } + return 'PostgreSQL backend pending connection'; + } + + /** + * Internal function to perform the PostgreSQL query call. + * + * @param string $query The query to run. + */ + private function _do_query( $query ) { + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->timer_start(); + } + + try { + $this->result = $this->dbh->query( $query ); + } catch ( Throwable $e ) { + $this->last_error = $this->format_error_message( $e ); + } + + ++$this->num_queries; + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->log_query( + $query, + $this->timer_stop(), + $this->get_caller(), + $this->time_start, + array() + ); + } + } + + /** + * Method to set the class variable $col_info. + * + * This overrides wpdb::load_col_info(), which uses mysqli metadata. + */ + protected function load_col_info() { + if ( $this->col_info ) { + return; + } + $this->col_info = array(); + foreach ( $this->dbh->get_last_column_meta() as $column ) { + $this->col_info[] = (object) array( + 'name' => $column['name'], + 'orgname' => $column['mysqli:orgname'], + 'table' => $column['table'], + 'orgtable' => $column['mysqli:orgtable'], + 'def' => '', + 'db' => $column['mysqli:db'], + 'catalog' => 'def', + 'max_length' => 0, + 'length' => $column['len'], + 'charsetnr' => $column['mysqli:charsetnr'], + 'flags' => $column['mysqli:flags'], + 'type' => $column['mysqli:type'], + 'decimals' => $column['precision'], + ); + } + } + + /** + * Builds PostgreSQL connection options from wpdb constructor state. + * + * @return array + */ + private function get_connection_options() { + $host = $this->dbhost; + $port = null; + + $host_data = $this->parse_db_host( $this->dbhost ); + if ( $host_data ) { + list( $host, $port, $socket ) = $host_data; + + if ( null !== $socket && '' !== $socket ) { + $host = $this->get_postgresql_socket_host( $socket ); + $socket_port = $this->get_postgresql_socket_port( $socket ); + if ( null === $port && null !== $socket_port ) { + $port = $socket_port; + } + } + } + + $options = array( + 'host' => $host, + 'port' => $port, + 'dbname' => $this->dbname, + 'user' => $this->dbuser, + 'password' => $this->dbpassword, + ); + + if ( isset( $GLOBALS['@pdo'] ) && $GLOBALS['@pdo'] instanceof PDO && $this->is_postgresql_pdo( $GLOBALS['@pdo'] ) ) { + $options['pdo'] = $GLOBALS['@pdo']; + } + + return $options; + } + + /** + * Returns the libpq socket directory when DB_HOST includes a socket file. + * + * @param string $socket Socket path or directory. + * @return string PostgreSQL host option. + */ + private function get_postgresql_socket_host( $socket ) { + $socket_file = basename( $socket ); + if ( 0 === strpos( $socket_file, '.s.PGSQL.' ) ) { + return dirname( $socket ); + } + return $socket; + } + + /** + * Returns the PostgreSQL port encoded in a socket file path. + * + * @param string $socket Socket path or directory. + * @return int|null PostgreSQL port. + */ + private function get_postgresql_socket_port( $socket ) { + $prefix = '.s.PGSQL.'; + $socket_file = basename( $socket ); + if ( 0 !== strpos( $socket_file, $prefix ) ) { + return null; + } + + $port = substr( $socket_file, strlen( $prefix ) ); + return ctype_digit( $port ) ? (int) $port : null; + } + + /** + * Checks whether a reusable PDO object is PostgreSQL-backed. + * + * @param PDO $pdo PDO instance. + * @return bool Whether the PDO driver is PostgreSQL. + */ + private function is_postgresql_pdo( PDO $pdo ) { + try { + return 'pgsql' === $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + } catch ( Throwable $e ) { + return false; + } + } + + /** + * Rewrites %i placeholders into PostgreSQL-quoted identifier strings. + * + * @param string $query Query statement with placeholders. + * @param array $args Placeholder arguments. + * @return array + */ + private function prepare_postgresql_identifiers( $query, array $args ) { + $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); + if ( $passed_as_array ) { + $args = $args[0]; + } + + $length = strlen( $query ); + $output = ''; + $placeholder_index = 0; + $changed = false; + + for ( $i = 0; $i < $length; $i++ ) { + if ( '%' !== $query[ $i ] ) { + $output .= $query[ $i ]; + continue; + } + + if ( $i + 1 < $length && '%' === $query[ $i + 1 ] ) { + $output .= '%%'; + ++$i; + continue; + } + + $placeholder = $this->parse_prepare_placeholder_at( $query, $i ); + if ( ! $placeholder ) { + $output .= '%'; + continue; + } + + if ( 'i' === $placeholder['type'] ) { + $arg_index = null !== $placeholder['arg_index'] ? $placeholder['arg_index'] : $placeholder_index; + if ( array_key_exists( $arg_index, $args ) ) { + $args[ $arg_index ] = $this->quote_identifier( $args[ $arg_index ] ); + } + $format = '' === $placeholder['format'] ? '+' : $placeholder['format']; + $output .= '%' . $format . 's'; + $changed = true; + } else { + $output .= substr( $query, $i, $placeholder['length'] ); + } + + $i += $placeholder['length'] - 1; + ++$placeholder_index; + } + + return array( + 'query' => $output, + 'args' => $args, + 'changed' => $changed, + ); + } + + /** + * Parses one wpdb::prepare() placeholder at the given string offset. + * + * @param string $query Query statement. + * @param int $offset Offset of the percent sign. + * @return array|null Placeholder information, or null if invalid. + */ + private function parse_prepare_placeholder_at( $query, $offset ) { + $allowed_format = '(?:[1-9][0-9]*[$])?[-+0-9]*(?: |0|\'.)?[-+0-9]*(?:\.[0-9]+)?'; + $segment = substr( $query, $offset ); + + if ( ! preg_match( '/^%(' . $allowed_format . ')([sdfFi])/', $segment, $matches ) ) { + return null; + } + + $format = $matches[1]; + $arg_index = null; + $dollar = strpos( $format, '$' ); + if ( false !== $dollar ) { + $arg_index = ( (int) substr( $format, 0, $dollar ) ) - 1; + } + + return array( + 'type' => $matches[2], + 'length' => strlen( $matches[0] ), + 'arg_index' => $arg_index, + 'format' => $format, + ); + } + + /** + * Returns the first SQL statement keyword. + * + * @param string $query SQL query. + * @return string Lowercase statement keyword, or empty string. + */ + private function get_statement_keyword( $query ) { + $length = strlen( $query ); + $i = 0; + + while ( $i < $length ) { + $char = $query[ $i ]; + if ( ctype_space( $char ) ) { + ++$i; + continue; + } + + if ( '-' === $char && $i + 1 < $length && '-' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i < $length && "\n" !== $query[ $i ] ) { + ++$i; + } + continue; + } + + if ( '/' === $char && $i + 1 < $length && '*' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i + 1 < $length && ! ( '*' === $query[ $i ] && '/' === $query[ $i + 1 ] ) ) { + ++$i; + } + $i += 2; + continue; + } + + break; + } + + $start = $i; + while ( $i < $length && ( ctype_alpha( $query[ $i ] ) || '_' === $query[ $i ] ) ) { + ++$i; + } + + return strtolower( substr( $query, $start, $i - $start ) ); + } + + /** + * Format PostgreSQL driver error message. + * + * @param Throwable $e Error. + * @return string Error message. + */ + private function format_error_message( Throwable $e ) { + return $e->getMessage(); } } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php index e11d13b51..1817c36f8 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php @@ -6,11 +6,12 @@ */ require_once __DIR__ . '/../database/version.php'; +require_once __DIR__ . '/../database/load.php'; require_once __DIR__ . '/../../constants.php'; if ( ! defined( 'DB_ENGINE' ) - || 'postgresql' !== sqlite_database_integration_normalize_db_engine( DB_ENGINE ) + || 'postgresql' !== wp_sqlite_database_integration_normalize_db_engine( DB_ENGINE ) ) { return; } From 56dbccee17e3074ac26376e1dbcd2899fe221066 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:13:53 +0000 Subject: [PATCH 005/142] Validate generated backend setup before PHPUnit --- .github/workflows/wp-tests-phpunit-run.js | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 580edb8a2..803325c0b 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -115,6 +115,9 @@ console.log( 'Expected errors:', expectedByBackend[ backend ].errors ); console.log( 'Expected failures:', expectedByBackend[ backend ].failures ); try { + ensureWordPressTestEnvironment(); + validateGeneratedBackendFiles(); + if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } @@ -216,6 +219,72 @@ function verifyNativeParserExtension() { ); } +function ensureWordPressTestEnvironment() { + execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); +} + +function validateGeneratedBackendFiles() { + if ( 'mysql' === backend ) { + return; + } + + const generatedDropin = path.join( repositoryRoot, 'wordpress', 'src', 'wp-content', 'db.php' ); + const composeOverride = path.join( repositoryRoot, 'wordpress', 'docker-compose.override.yml' ); + + assertFileContains( + generatedDropin, + `: '${ backend }'`, + `generated db.php default backend is ${ backend }` + ); + assertFileContains( + generatedDropin, + "$unreplaced_database_engine = '{' . 'DATABASE_ENGINE' . '}';", + 'generated db.php uses a split database-engine sentinel' + ); + assertFileContains( + generatedDropin, + "/wp-includes/db.php'", + 'generated db.php loads the backend dispatcher' + ); + assertFileDoesNotContain( + generatedDropin, + "require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/db.php';", + 'generated db.php does not load the SQLite drop-in directly' + ); + assertFileContains( + composeOverride, + `DB_ENGINE: ${ backend }`, + `docker-compose.override.yml sets DB_ENGINE=${ backend }` + ); + assertFileContains( + composeOverride, + `DATABASE_ENGINE: ${ backend }`, + `docker-compose.override.yml sets DATABASE_ENGINE=${ backend }` + ); +} + +function assertFileContains( file, expected, description ) { + const contents = readGeneratedFile( file ); + if ( ! contents.includes( expected ) ) { + throw new Error( `Expected ${ description } in ${ file }.` ); + } +} + +function assertFileDoesNotContain( file, unexpected, description ) { + const contents = readGeneratedFile( file ); + if ( contents.includes( unexpected ) ) { + throw new Error( `Expected ${ description } in ${ file }.` ); + } +} + +function readGeneratedFile( file ) { + if ( ! fs.existsSync( file ) ) { + throw new Error( `Expected generated file to exist: ${ file }.` ); + } + + return fs.readFileSync( file, 'utf8' ); +} + function readJunitTestcases( junitOutputFile ) { const parserPath = require.resolve( 'fast-xml-parser', { paths: [ From a20a3e8bd8f994611fdb10288ac28f73011d8673 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:22:49 +0000 Subject: [PATCH 006/142] Add PostgreSQL local-env setup --- .github/workflows/wp-tests-phpunit-run.js | 41 ++++++ .github/workflows/wp-tests-phpunit.yml | 8 +- .../tests/WP_PostgreSQL_Connection_Tests.php | 3 +- .../postgresql/class-wp-postgresql-db.php | 96 +------------- wp-setup.sh | 121 +++++++++++++++++- 5 files changed, 170 insertions(+), 99 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 803325c0b..ee06673ea 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -121,6 +121,9 @@ try { if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } + if ( 'postgresql' === backend ) { + verifyPostgreSqlPhpExtension(); + } try { execSync( @@ -219,6 +222,20 @@ function verifyNativeParserExtension() { ); } +function verifyPostgreSqlPhpExtension() { + verifyContainerPhpExtension( 'php', 'pdo_pgsql' ); + verifyContainerPhpExtension( 'cli', 'pdo_pgsql' ); +} + +function verifyContainerPhpExtension( service, extensionName ) { + const phpCode = `if ( ! extension_loaded( '${ extensionName }' ) ) { fwrite( STDERR, '${ extensionName } is missing in the ${ service } container.\\n' ); exit( 1 ); }`; + const runArgs = 'cli' === service ? '--rm --entrypoint php cli' : '--rm php php'; + execSync( + `cd wordpress && node tools/local-env/scripts/docker.js run ${ runArgs } -r "${ phpCode }"`, + { stdio: 'inherit' } + ); +} + function ensureWordPressTestEnvironment() { execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); } @@ -261,6 +278,30 @@ function validateGeneratedBackendFiles() { `DATABASE_ENGINE: ${ backend }`, `docker-compose.override.yml sets DATABASE_ENGINE=${ backend }` ); + + if ( 'postgresql' === backend ) { + const installScript = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'scripts', 'install.js' ); + assertFileContains( + composeOverride, + 'postgres:', + 'docker-compose.override.yml defines a PostgreSQL service' + ); + assertFileContains( + installScript, + '--dbhost=postgres', + 'install.js creates wp-config.php with the PostgreSQL host' + ); + assertFileContains( + installScript, + "config set DB_ENGINE postgresql", + 'install.js writes DB_ENGINE=postgresql' + ); + assertFileContains( + installScript, + "define( 'DATABASE_ENGINE', 'postgresql' );", + 'install.js writes DATABASE_ENGINE=postgresql to wp-tests-config.php' + ); + } } function assertFileContains( file, expected, description ) { diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index d1cf34079..d74cd0f6a 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -104,8 +104,14 @@ jobs: - name: Download PHPUnit count artifacts uses: actions/download-artifact@v4 with: - pattern: wp-phpunit-* 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 diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index b08debe5c..5446f9d57 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -136,13 +136,12 @@ public function test_quote_identifier_value_rejects_nul_bytes(): void { /** * Tests injected PDO instances are configured and reused. */ - public function test_constructor_uses_injected_pdo_and_sets_expected_attributes(): void { + public function test_constructor_uses_injected_pdo_and_sets_exception_mode(): void { $pdo = new PDO( 'sqlite::memory:' ); $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); $this->assertSame( $pdo, $connection->get_pdo() ); $this->assertSame( PDO::ERRMODE_EXCEPTION, $pdo->getAttribute( PDO::ATTR_ERRMODE ) ); - $this->assertTrue( $pdo->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ); } /** diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 8f7274f25..666b27555 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -246,11 +246,6 @@ public function prepare( $query, ...$args ) { return parent::prepare( $query, ...$args ); } - $prepared = $this->prepare_postgresql_identifiers( (string) $query, $args ); - if ( $prepared['changed'] ) { - return parent::prepare( $prepared['query'], $prepared['args'] ); - } - return parent::prepare( $query, ...$args ); } @@ -327,7 +322,7 @@ public function query( $query ) { * @return bool Whether the database feature is supported. */ public function has_cap( $db_cap ) { - return in_array( strtolower( $db_cap ), array( 'identifier_placeholders', 'subqueries' ), true ); + return 'subqueries' === strtolower( $db_cap ); } /** @@ -491,95 +486,6 @@ private function is_postgresql_pdo( PDO $pdo ) { } } - /** - * Rewrites %i placeholders into PostgreSQL-quoted identifier strings. - * - * @param string $query Query statement with placeholders. - * @param array $args Placeholder arguments. - * @return array - */ - private function prepare_postgresql_identifiers( $query, array $args ) { - $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); - if ( $passed_as_array ) { - $args = $args[0]; - } - - $length = strlen( $query ); - $output = ''; - $placeholder_index = 0; - $changed = false; - - for ( $i = 0; $i < $length; $i++ ) { - if ( '%' !== $query[ $i ] ) { - $output .= $query[ $i ]; - continue; - } - - if ( $i + 1 < $length && '%' === $query[ $i + 1 ] ) { - $output .= '%%'; - ++$i; - continue; - } - - $placeholder = $this->parse_prepare_placeholder_at( $query, $i ); - if ( ! $placeholder ) { - $output .= '%'; - continue; - } - - if ( 'i' === $placeholder['type'] ) { - $arg_index = null !== $placeholder['arg_index'] ? $placeholder['arg_index'] : $placeholder_index; - if ( array_key_exists( $arg_index, $args ) ) { - $args[ $arg_index ] = $this->quote_identifier( $args[ $arg_index ] ); - } - $format = '' === $placeholder['format'] ? '+' : $placeholder['format']; - $output .= '%' . $format . 's'; - $changed = true; - } else { - $output .= substr( $query, $i, $placeholder['length'] ); - } - - $i += $placeholder['length'] - 1; - ++$placeholder_index; - } - - return array( - 'query' => $output, - 'args' => $args, - 'changed' => $changed, - ); - } - - /** - * Parses one wpdb::prepare() placeholder at the given string offset. - * - * @param string $query Query statement. - * @param int $offset Offset of the percent sign. - * @return array|null Placeholder information, or null if invalid. - */ - private function parse_prepare_placeholder_at( $query, $offset ) { - $allowed_format = '(?:[1-9][0-9]*[$])?[-+0-9]*(?: |0|\'.)?[-+0-9]*(?:\.[0-9]+)?'; - $segment = substr( $query, $offset ); - - if ( ! preg_match( '/^%(' . $allowed_format . ')([sdfFi])/', $segment, $matches ) ) { - return null; - } - - $format = $matches[1]; - $arg_index = null; - $dollar = strpos( $format, '$' ); - if ( false !== $dollar ) { - $arg_index = ( (int) substr( $format, 0, $dollar ) ) - 1; - } - - return array( - 'type' => $matches[2], - 'length' => strlen( $matches[0] ), - 'arg_index' => $arg_index, - 'format' => $format, - ); - } - /** * Returns the first SQL statement keyword. * diff --git a/wp-setup.sh b/wp-setup.sh index b7a2bc224..a3bf921d1 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -43,7 +43,7 @@ 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" -if [ "$WP_TEST_DB_BACKEND" != "mysql" ]; then +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" @@ -76,6 +76,74 @@ services: - ../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/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: + 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@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + 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@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + 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: + php: + condition: service_started + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + 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: + postgres: {} +EOF fi if [ "$WP_TEST_DB_BACKEND" != "mysql" ]; then @@ -99,6 +167,57 @@ 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 + + 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: "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' );", + "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: "\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\" );", + ], + }, +]; + +const found = new Set(); +const output = []; +for ( const line of fs.readFileSync( file, 'utf8' ).split( '\n' ) ) { + const replacement = replacements.find( candidate => candidate.from === line ); + if ( replacement ) { + found.add( replacement.from ); + output.push( ...replacement.to ); + } else { + output.push( line ); + } +} + +for ( const replacement of replacements ) { + if ( ! found.has( replacement.from ) ) { + throw new Error( `Expected line not found in ${ file }: ${ replacement.from }` ); + } +} + +fs.writeFileSync( file, output.join( '\n' ) ); +NODE fi # 6. Install dependencies. From 53d02c60d95f1e6f5c265d64665d8179375675f4 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:31:59 +0000 Subject: [PATCH 007/142] Skip PostgreSQL wp-config connection check --- .github/workflows/wp-tests-phpunit-run.js | 5 +++++ wp-setup.sh | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ee06673ea..d1bce4735 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -291,6 +291,11 @@ function validateGeneratedBackendFiles() { '--dbhost=postgres', 'install.js creates wp-config.php with the PostgreSQL host' ); + assertFileContains( + installScript, + '--skip-check', + 'install.js skips MySQL-style connection checks while creating PostgreSQL wp-config.php' + ); assertFileContains( installScript, "config set DB_ENGINE postgresql", diff --git a/wp-setup.sh b/wp-setup.sh index a3bf921d1..be6381d9a 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -177,7 +177,7 @@ const replacements = [ { 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' );", + "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 1486fcd9567fea7eee62c7f6a268af27ad8ecb2b Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:38:21 +0000 Subject: [PATCH 008/142] Bypass MySQL WP-CLI install steps for PostgreSQL --- .github/workflows/wp-tests-phpunit-run.js | 10 ++++++++++ wp-setup.sh | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index d1bce4735..4a6750279 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -306,6 +306,16 @@ function validateGeneratedBackendFiles() { "define( 'DATABASE_ENGINE', 'postgresql' );", 'install.js writes DATABASE_ENGINE=postgresql to wp-tests-config.php' ); + assertFileDoesNotContain( + installScript, + "wp_cli( 'db reset --yes' );", + 'install.js does not call the MySQL-backed db reset command for PostgreSQL' + ); + assertFileDoesNotContain( + installScript, + `core \${ installCommand }`, + 'install.js does not call the MySQL-backed core install command for PostgreSQL' + ); } } diff --git a/wp-setup.sh b/wp-setup.sh index be6381d9a..123fd4a24 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -196,6 +196,24 @@ const replacements = [ "\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 found = new Set(); From 0e8fc3197a984dd03fa0ede90313a721b24d2f37 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:44:25 +0000 Subject: [PATCH 009/142] Skip PostgreSQL WP-CLI plugin setup --- .github/workflows/wp-tests-phpunit-run.js | 5 +++++ wp-setup.sh | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 4a6750279..42a227c28 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -311,6 +311,11 @@ function validateGeneratedBackendFiles() { "wp_cli( 'db reset --yes' );", 'install.js does not call the MySQL-backed db reset command for PostgreSQL' ); + assertFileDoesNotContain( + installScript, + 'install_wp_importer();', + 'install.js does not call WP-CLI plugin installation for PostgreSQL' + ); assertFileDoesNotContain( installScript, `core \${ installCommand }`, diff --git a/wp-setup.sh b/wp-setup.sh index 123fd4a24..ba628c849 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -196,6 +196,12 @@ const replacements = [ "\t.concat( \"define( 'FS_METHOD', 'direct' );\\n\" );", ], }, + { + from: "install_wp_importer();", + to: [ + "// Skip WP-CLI plugin installation until the PostgreSQL runtime is wired.", + ], + }, { from: "\t\twp_cli( 'db reset --yes' );", to: [ From ba6bfd65e13c4034fe02b53806e1e4e840768907 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:52:33 +0000 Subject: [PATCH 010/142] Start PostgreSQL test env without plugin installs --- .github/workflows/wp-tests-phpunit-run.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 42a227c28..d3e2c48f1 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -237,9 +237,28 @@ function verifyContainerPhpExtension( service, extensionName ) { } function ensureWordPressTestEnvironment() { + if ( 'postgresql' === backend ) { + ensurePostgreSqlWordPressTestEnvironment(); + return; + } + execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); } +function ensurePostgreSqlWordPressTestEnvironment() { + execSync( 'if [ ! -f wordpress/src/wp-load.php ]; then composer run wp-setup; fi', { stdio: 'inherit' } ); + execSync( + 'cd wordpress && if [ -z "$(node tools/local-env/scripts/docker.js ps -q)" ]; then npm run env:start && npm run env:install; fi', + { + env: { + ...process.env, + COMPOSE_IGNORE_ORPHANS: 'true', + }, + stdio: 'inherit', + } + ); +} + function validateGeneratedBackendFiles() { if ( 'mysql' === backend ) { return; From f93b4ed37d21aea401d1389b8b35ff4bbc0c10db Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 17:59:21 +0000 Subject: [PATCH 011/142] Build PostgreSQL PHP test images --- .github/workflows/wp-tests-phpunit-run.js | 22 +++++++++++ wp-setup.sh | 46 ++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index d3e2c48f1..edb8d3692 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -300,11 +300,33 @@ function validateGeneratedBackendFiles() { if ( 'postgresql' === backend ) { const installScript = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'scripts', 'install.js' ); + const postgresqlPhpDockerfile = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'Dockerfile.postgresql-php' ); + const postgresqlCliDockerfile = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'Dockerfile.postgresql-cli' ); assertFileContains( composeOverride, 'postgres:', 'docker-compose.override.yml defines a PostgreSQL service' ); + assertFileContains( + composeOverride, + 'Dockerfile.postgresql-php', + 'docker-compose.override.yml builds a PostgreSQL PHP image' + ); + assertFileContains( + composeOverride, + 'Dockerfile.postgresql-cli', + 'docker-compose.override.yml builds a PostgreSQL CLI image' + ); + assertFileContains( + postgresqlPhpDockerfile, + 'docker-php-ext-install pdo_pgsql', + 'PostgreSQL PHP Dockerfile installs pdo_pgsql' + ); + assertFileContains( + postgresqlCliDockerfile, + 'docker-php-ext-install pdo_pgsql', + 'PostgreSQL CLI Dockerfile installs pdo_pgsql' + ); assertFileContains( installScript, '--dbhost=postgres', diff --git a/wp-setup.sh b/wp-setup.sh index ba628c849..de7f4c4cf 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -81,6 +81,42 @@ elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then 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 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 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: @@ -99,7 +135,10 @@ services: php: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 - image: wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + image: wordpressdevelop/php-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-php environment: DB_ENGINE: postgresql DATABASE_ENGINE: postgresql @@ -109,7 +148,10 @@ services: cli: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 - image: wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + image: wordpressdevelop/cli-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-cli environment: DB_ENGINE: postgresql DATABASE_ENGINE: postgresql From 72a2d7865a97ef13ebf95d61e4b4808bd7df1fe2 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:11:27 +0000 Subject: [PATCH 012/142] Use file-based PostgreSQL extension verifier --- .github/workflows/wp-tests-phpunit-run.js | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index edb8d3692..1cdab8828 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -223,15 +223,32 @@ function verifyNativeParserExtension() { } function verifyPostgreSqlPhpExtension() { - verifyContainerPhpExtension( 'php', 'pdo_pgsql' ); - verifyContainerPhpExtension( 'cli', 'pdo_pgsql' ); + const verifier = writePostgreSqlPhpExtensionVerifier(); + verifyContainerPhpExtension( 'php', verifier ); + verifyContainerPhpExtension( 'cli', verifier ); } -function verifyContainerPhpExtension( service, extensionName ) { - const phpCode = `if ( ! extension_loaded( '${ extensionName }' ) ) { fwrite( STDERR, '${ extensionName } is missing in the ${ service } container.\\n' ); exit( 1 ); }`; +function writePostgreSqlPhpExtensionVerifier() { + const verifier = path.join( repositoryRoot, 'wordpress', 'postgresql-verify-extension.php' ); + fs.writeFileSync( + verifier, + ` Date: Tue, 9 Jun 2026 18:18:06 +0000 Subject: [PATCH 013/142] Avoid mysqli error handling in PostgreSQL wpdb --- .../postgresql/class-wp-postgresql-db.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 666b27555..6d119ae02 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -135,6 +135,76 @@ public function _real_escape( $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. * From f6fe13a66496108ddde01c5740112abed42e117f Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:18:49 +0000 Subject: [PATCH 014/142] Assert PostgreSQL DSN excludes credentials --- .../tests/WP_PostgreSQL_Connection_Tests.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index 5446f9d57..e1cd09291 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -22,6 +22,24 @@ public function test_build_dsn_from_structured_options(): void { ); } + /** + * 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. * From 1b192085ca1bc61ff4eeae811a907d22a116ec16 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:19:41 +0000 Subject: [PATCH 015/142] Regenerate stale backend test checkouts --- .github/workflows/wp-tests-phpunit-run.js | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 1cdab8828..899b203b2 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -115,6 +115,7 @@ console.log( 'Expected errors:', expectedByBackend[ backend ].errors ); console.log( 'Expected failures:', expectedByBackend[ backend ].failures ); try { + ensureGeneratedBackendFiles(); ensureWordPressTestEnvironment(); validateGeneratedBackendFiles(); @@ -263,7 +264,6 @@ function ensureWordPressTestEnvironment() { } function ensurePostgreSqlWordPressTestEnvironment() { - execSync( 'if [ ! -f wordpress/src/wp-load.php ]; then composer run wp-setup; fi', { stdio: 'inherit' } ); execSync( 'cd wordpress && if [ -z "$(node tools/local-env/scripts/docker.js ps -q)" ]; then npm run env:start && npm run env:install; fi', { @@ -276,6 +276,37 @@ function ensurePostgreSqlWordPressTestEnvironment() { ); } +function ensureGeneratedBackendFiles() { + if ( 'mysql' === backend ) { + return; + } + + const wpLoad = path.join( repositoryRoot, 'wordpress', 'src', 'wp-load.php' ); + if ( ! fs.existsSync( wpLoad ) ) { + runWordPressSetup(); + validateGeneratedBackendFiles(); + return; + } + + try { + validateGeneratedBackendFiles(); + } catch ( error ) { + console.error( `Generated WordPress checkout is stale for ${ backend }: ${ error.message }` ); + runWordPressSetup(); + validateGeneratedBackendFiles(); + } +} + +function runWordPressSetup() { + execSync( 'composer run wp-setup', { + env: { + ...process.env, + WP_TEST_DB_BACKEND: backend, + }, + stdio: 'inherit', + } ); +} + function validateGeneratedBackendFiles() { if ( 'mysql' === backend ) { return; From ed1c93c5a08bc930b1687f4c4104addb5c4107d2 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:20:40 +0000 Subject: [PATCH 016/142] Drop MySQL from PostgreSQL test compose --- wp-setup.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wp-setup.sh b/wp-setup.sh index de7f4c4cf..f02e50fac 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -128,6 +128,7 @@ services: - ../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: @@ -159,11 +160,14 @@ services: - ../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 networks: @@ -184,6 +188,7 @@ services: retries: 10 volumes: + mysql: !reset null postgres: {} EOF fi From 061b695697fc15c2432972c2c3772ea726433f7e Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:22:11 +0000 Subject: [PATCH 017/142] Validate PostgreSQL compose MySQL reset --- .github/workflows/wp-tests-phpunit-run.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 899b203b2..22e0b8f3c 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -365,6 +365,11 @@ function validateGeneratedBackendFiles() { 'Dockerfile.postgresql-cli', 'docker-compose.override.yml builds a PostgreSQL CLI image' ); + assertFileContains( + composeOverride, + 'mysql: !reset null', + 'docker-compose.override.yml removes inherited MySQL services and dependencies' + ); assertFileContains( postgresqlPhpDockerfile, 'docker-php-ext-install pdo_pgsql', From b77e254743e9e88f0099638f11b87a4ccfc5ce7a Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:22:21 +0000 Subject: [PATCH 018/142] Cover PostgreSQL driver state reset --- .../tests/WP_PostgreSQL_Driver_Tests.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index bad0c9718..5555f44df 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -51,6 +51,33 @@ public function test_write_query_returns_row_count(): void { $this->assertSame( 0, $driver->get_last_column_count() ); } + /** + * Tests successive queries reset result metadata and backend query logs. + */ + public function test_query_resets_per_query_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT 1 AS id' ); + $this->assertSame( 1, $driver->get_last_column_count() ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + + $result = $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + + $this->assertSame( $result, $driver->get_query_results() ); + $this->assertSame( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + /** * Tests insert IDs are cast to integers when numeric. */ @@ -77,6 +104,23 @@ public function test_transaction_methods_delegate_to_pdo(): void { $this->assertFalse( $stmt->fetchColumn() ); } + /** + * Tests the transaction alias and commit delegate to PDO. + */ + public function test_transaction_alias_and_commit_delegate_to_pdo(): void { + $driver = $this->create_driver(); + + $driver->begin_transaction(); + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + $driver->commit(); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'first', $rows[0]->value ); + } + /** * Creates a PostgreSQL driver backed by an injected in-memory PDO. * From dccf830a3ba5aba6e44d770b0334e3fea9ecc4dd Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:26:51 +0000 Subject: [PATCH 019/142] Add PostgreSQL create table translator --- packages/mysql-on-sqlite/src/load.php | 1 + ...-wp-postgresql-create-table-translator.php | 393 ++++++++++++++++++ ...stgreSQL_Create_Table_Translator_Tests.php | 111 +++++ 3 files changed, 505 insertions(+) create mode 100644 packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 71ad193fa..20436aadd 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -44,4 +44,5 @@ require_once __DIR__ . '/sqlite/class-wp-pdo-mysql-on-sqlite.php'; require_once __DIR__ . '/sqlite/class-wp-pdo-proxy-statement.php'; require_once __DIR__ . '/postgresql/class-wp-postgresql-connection.php'; +require_once __DIR__ . '/postgresql/class-wp-postgresql-create-table-translator.php'; require_once __DIR__ . '/postgresql/class-wp-postgresql-driver.php'; diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php new file mode 100644 index 000000000..6cc220380 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php @@ -0,0 +1,393 @@ +create_parser( $sql ); + $statements = array(); + + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( ! $ast || ! $ast->has_child() ) { + continue; + } + + foreach ( $this->translate( $ast ) as $statement ) { + $statements[] = $statement; + } + } + + return $statements; + } + + /** + * Translate a parsed CREATE TABLE statement. + * + * @param WP_Parser_Node $create_statement Parsed query/createStatement/createTable node. + * @return string[] PostgreSQL DDL statements. + */ + public function translate( WP_Parser_Node $create_statement ): array { + $create_table = $this->get_create_table_node( $create_statement ); + if ( ! $create_table ) { + throw new InvalidArgumentException( 'Only CREATE TABLE statements are supported by the PostgreSQL DDL translator.' ); + } + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + throw new InvalidArgumentException( 'CREATE TABLE ... AS SELECT is not supported by the PostgreSQL DDL translator.' ); + } + + $table_name = $this->get_table_name( $create_table ); + $if_not_exists = $create_table->has_child_node( 'ifNotExists' ); + $columns = array(); + $constraints = array(); + $indexes = array(); + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $columns[] = $this->translate_column_definition( $column_definition ); + continue; + } + + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( ! $table_constraint ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE element.' ); + } + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $constraints[] = 'PRIMARY KEY (' . implode( ', ', $this->quote_key_parts( $table_constraint ) ) . ')'; + continue; + } + + $indexes[] = $this->translate_secondary_index( $table_constraint, $table_name, $if_not_exists ); + } + + $definitions = array_merge( $columns, $constraints ); + if ( empty( $definitions ) ) { + throw new InvalidArgumentException( 'CREATE TABLE statement does not define any columns.' ); + } + + $create_sql = sprintf( + 'CREATE %sTABLE %s%s (%s%s%s)', + $create_table->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ) ? 'TEMPORARY ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name ), + "\n ", + implode( ",\n ", $definitions ), + "\n" + ); + + return array_merge( array( $create_sql ), $indexes ); + } + + /** + * Create a parser for a MySQL SQL string. + * + * @param string $sql MySQL SQL. + * @return WP_MySQL_Parser Parser instance. + */ + private function create_parser( string $sql ): WP_MySQL_Parser { + $lexer = new WP_MySQL_Lexer( $sql, 80038, array() ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + return new WP_MySQL_Parser( $this->get_mysql_grammar(), $tokens ); + } + + /** + * Get the parser grammar. + * + * @return WP_Parser_Grammar MySQL grammar. + */ + private function get_mysql_grammar(): WP_Parser_Grammar { + if ( null === self::$mysql_grammar ) { + self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH ); + } + + return self::$mysql_grammar; + } + + /** + * Locate a createTable node from accepted AST entry points. + * + * @param WP_Parser_Node $node Parsed node. + * @return WP_Parser_Node|null createTable node. + */ + private function get_create_table_node( WP_Parser_Node $node ): ?WP_Parser_Node { + if ( 'createTable' === $node->rule_name ) { + return $node; + } + + if ( 'createStatement' === $node->rule_name ) { + return $node->get_first_child_node( 'createTable' ); + } + + $simple_statement = $node->get_first_child_node( 'simpleStatement' ); + if ( $simple_statement ) { + $create_statement = $simple_statement->get_first_child_node( 'createStatement' ); + return $create_statement ? $create_statement->get_first_child_node( 'createTable' ) : null; + } + + return null; + } + + /** + * Translate a MySQL column definition. + * + * @param WP_Parser_Node $column_definition Column definition node. + * @return string PostgreSQL column definition. + */ + private function translate_column_definition( WP_Parser_Node $column_definition ): string { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + if ( ! $field_definition ) { + throw new InvalidArgumentException( 'Column definition is missing a field definition.' ); + } + + $is_auto_increment = null !== $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ); + $parts = array( + $this->quote_identifier( $name ), + $this->translate_data_type( $field_definition->get_first_child_node( 'dataType' ), $is_auto_increment ), + ); + + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::NOT_SYMBOL ) ) { + $parts[] = 'NOT NULL'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $parts[] = $this->translate_default_attribute( $attribute ); + } + } + + return implode( ' ', $parts ); + } + + /** + * Translate a MySQL data type. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param bool $is_auto_increment Whether AUTO_INCREMENT is present. + * @return string PostgreSQL data type. + */ + private function translate_data_type( ?WP_Parser_Node $data_type, bool $is_auto_increment ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + $type = strtolower( $type_token->get_value() ); + if ( 'bigint' === $type ) { + $postgresql_type = 'bigint'; + } elseif ( in_array( $type, array( 'int', 'integer', 'mediumint', 'smallint', 'tinyint' ), true ) ) { + $postgresql_type = 'integer'; + } elseif ( in_array( $type, array( 'varchar', 'char' ), true ) ) { + $length = $this->get_field_length( $data_type ); + $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; + } elseif ( in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'datetime', 'timestamp', 'date', 'time', 'year' ), true ) ) { + $postgresql_type = 'text'; + } else { + throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); + } + + if ( $is_auto_increment ) { + return $postgresql_type . ' GENERATED BY DEFAULT AS IDENTITY'; + } + + return $postgresql_type; + } + + /** + * Translate a simple DEFAULT attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return string PostgreSQL DEFAULT clause. + */ + private function translate_default_attribute( WP_Parser_Node $attribute ): string { + $value_token = $attribute->get_first_descendant_token( WP_MySQL_Lexer::NULL_SYMBOL ); + if ( $value_token ) { + return 'DEFAULT NULL'; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return 'DEFAULT ' . $this->quote_string_literal( $token->get_value() ); + } + } + + throw new InvalidArgumentException( 'Unsupported column DEFAULT expression.' ); + } + + /** + * Translate a non-primary MySQL key to a PostgreSQL CREATE INDEX statement. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name. + * @param bool $if_not_exists Whether CREATE TABLE used IF NOT EXISTS. + * @return string PostgreSQL CREATE INDEX statement. + */ + private function translate_secondary_index( WP_Parser_Node $table_constraint, string $table_name, bool $if_not_exists ): string { + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name = $index_name_node ? $this->get_identifier_value( $index_name_node->get_first_child_node( 'indexName' ) ) : null; + if ( null === $index_name || '' === $index_name ) { + $key_parts = $this->get_key_parts( $table_constraint ); + $index_name = $key_parts[0]; + } + + return sprintf( + 'CREATE %sINDEX %s%s ON %s (%s)', + $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? 'UNIQUE ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name . '__' . $index_name ), + $this->quote_identifier( $table_name ), + implode( ', ', $this->quote_key_parts( $table_constraint ) ) + ); + } + + /** + * Get quoted key parts from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Quoted PostgreSQL column names. + */ + private function quote_key_parts( WP_Parser_Node $table_constraint ): array { + return array_map( array( $this, 'quote_identifier' ), $this->get_key_parts( $table_constraint ) ); + } + + /** + * Get key part column names. + * + * Prefix lengths, e.g. meta_key(191), are deliberately ignored for the + * initial WordPress install slice. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Column names. + */ + private function get_key_parts( WP_Parser_Node $table_constraint ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $key_parts[] = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get a CREATE TABLE table name. + * + * @param WP_Parser_Node $create_table Create table node. + * @return string Table name. + */ + private function get_table_name( WP_Parser_Node $create_table ): string { + return $this->get_identifier_value( $create_table->get_first_child_node( 'tableName' ) ); + } + + /** + * 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.' ); + } + + foreach ( $node->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $token->get_value(); + } + } + + throw new InvalidArgumentException( 'Expected identifier token.' ); + } + + /** + * 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; + } + + /** + * 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/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..1e262375e --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php @@ -0,0 +1,111 @@ +assertSame( + array( + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + 'CREATE INDEX "wp_options__autoload" ON "wp_options" ("autoload")', + ), + $this->translate( + "CREATE TABLE wp_options ( + option_id bigint(20) unsigned NOT NULL auto_increment, + option_name varchar(191) NOT NULL default '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL default 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) DEFAULT CHARACTER SET utf8mb4" + ) + ); + } + + /** + * Tests a compound primary key and secondary index. + */ + public function test_translate_wp_term_relationships_create_table(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_term_relationships\" (\n \"object_id\" bigint NOT NULL DEFAULT '0',\n \"term_taxonomy_id\" bigint NOT NULL DEFAULT '0',\n \"term_order\" integer NOT NULL DEFAULT '0',\n PRIMARY KEY (\"object_id\", \"term_taxonomy_id\")\n)", + 'CREATE INDEX "wp_term_relationships__term_taxonomy_id" ON "wp_term_relationships" ("term_taxonomy_id")', + ), + $this->translate( + 'CREATE TABLE wp_term_relationships ( + object_id bigint(20) unsigned NOT NULL default 0, + term_taxonomy_id bigint(20) unsigned NOT NULL default 0, + term_order int(11) NOT NULL default 0, + PRIMARY KEY (object_id,term_taxonomy_id), + KEY term_taxonomy_id (term_taxonomy_id) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests MySQL prefix index lengths are stripped from PostgreSQL index columns. + */ + public function test_translate_strips_prefix_index_lengths(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_postmeta\" (\n \"meta_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"meta_key\" varchar(255) DEFAULT NULL,\n PRIMARY KEY (\"meta_id\")\n)", + 'CREATE INDEX "wp_postmeta__meta_key" ON "wp_postmeta" ("meta_key")', + ), + $this->translate( + 'CREATE TABLE wp_postmeta ( + meta_id bigint(20) unsigned NOT NULL auto_increment, + meta_key varchar(255) default NULL, + PRIMARY KEY (meta_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests temporary IF NOT EXISTS statements. + */ + public function test_translate_temporary_if_not_exists(): void { + $this->assertSame( + array( + "CREATE TEMPORARY TABLE IF NOT EXISTS \"wp_tmp\" (\n \"id\" integer NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX IF NOT EXISTS "wp_tmp__id_idx" ON "wp_tmp" ("id")', + ), + $this->translate( + 'CREATE TEMPORARY TABLE IF NOT EXISTS wp_tmp ( + id int(11) NOT NULL, + PRIMARY KEY (id), + KEY id_idx (id) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests unsupported CREATE TABLE ... SELECT statements are rejected. + */ + public function test_translate_rejects_create_table_as_select(): void { + $this->expectException( InvalidArgumentException::class ); + $this->translate( 'CREATE TABLE wp_copy AS SELECT 1 AS id' ); + } + + /** + * Translates MySQL CREATE TABLE SQL. + * + * @param string $sql MySQL CREATE TABLE statement. + * @return string[] PostgreSQL DDL statements. + */ + private function translate( string $sql ): array { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + return $translator->translate_schema( $sql ); + } +} From 979358226076b3791232f54006887715fbcce204 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:33:20 +0000 Subject: [PATCH 020/142] Ignore local PHPUnit result summaries --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 ._* From 374f2da0af67ce5ab98c083cd6061bd8eb3b3bac Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:33:26 +0000 Subject: [PATCH 021/142] Harden PostgreSQL local test setup --- .github/workflows/wp-tests-phpunit-run.js | 30 +++++++++++++++++++++++ wp-setup.sh | 9 +++++++ 2 files changed, 39 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 22e0b8f3c..5276139a5 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -370,6 +370,28 @@ function validateGeneratedBackendFiles() { 'mysql: !reset null', 'docker-compose.override.yml removes inherited MySQL services and dependencies' ); + assertFileContainsCount( + composeOverride, + 'mysql: !reset null', + 4, + 'docker-compose.override.yml resets both inherited MySQL dependencies, the MySQL service, and the MySQL volume' + ); + assertFileContainsCount( + composeOverride, + ' depends_on:\n mysql: !reset null\n php:', + 2, + 'docker-compose.override.yml removes inherited MySQL dependencies from WordPress and CLI services' + ); + assertFileContains( + composeOverride, + '\n mysql: !reset null\n\n postgres:', + 'docker-compose.override.yml removes the inherited MySQL service before defining PostgreSQL' + ); + assertFileContains( + composeOverride, + '\nvolumes:\n mysql: !reset null\n postgres: {}', + 'docker-compose.override.yml removes the inherited MySQL volume' + ); assertFileContains( postgresqlPhpDockerfile, 'docker-php-ext-install pdo_pgsql', @@ -425,6 +447,14 @@ function assertFileContains( file, expected, description ) { } } +function assertFileContainsCount( file, expected, count, description ) { + const contents = readGeneratedFile( file ); + const actual = contents.split( expected ).length - 1; + if ( actual !== count ) { + throw new Error( `Expected ${ description } in ${ file }; found ${ actual }, expected ${ count }.` ); + } +} + function assertFileDoesNotContain( file, unexpected, description ) { const contents = readGeneratedFile( file ); if ( contents.includes( unexpected ) ) { diff --git a/wp-setup.sh b/wp-setup.sh index f02e50fac..fcb81f47f 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -39,6 +39,15 @@ 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 '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 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" From ed56f170c297230432e2b6d2c5206ff7c6a56e0e Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:33:30 +0000 Subject: [PATCH 022/142] Translate PostgreSQL install DDL in driver --- .../postgresql/class-wp-postgresql-driver.php | 160 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 49 ++++++ 2 files changed, 209 insertions(+) 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 index e28197e63..7562a5e50 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -144,6 +144,17 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $this->reset_query_state(); $this->last_mysql_query = $query; + if ( $this->is_noop_mysql_runtime_setting( $query ) ) { + $this->last_result = 0; + return $this->last_result; + } + + if ( $this->is_create_table_query( $query ) ) { + return $this->execute_postgresql_statements( + ( new WP_PostgreSQL_Create_Table_Translator() )->translate_schema( $query ) + ); + } + $stmt = $this->connection->query( $query ); $this->last_postgresql_queries[] = array( 'sql' => $query, @@ -161,6 +172,26 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + /** + * Execute translated PostgreSQL statements for a single MySQL-facing query. + * + * @param string[] $statements PostgreSQL SQL statements to execute. + * @return mixed Return value from the last executed statement. + */ + private function execute_postgresql_statements( array $statements ) { + foreach ( $statements as $statement ) { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = $stmt->rowCount(); + } + + $this->last_column_meta = array(); + return $this->last_result; + } + /** * Get results of the last query. * @@ -237,6 +268,135 @@ private function reset_query_state(): void { $this->last_postgresql_queries = array(); } + /** + * Check whether a query is a supported MySQL CREATE TABLE statement. + * + * @param string $query MySQL query. + * @return bool Whether the query should be translated before execution. + */ + private function is_create_table_query( string $query ): bool { + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id + && $this->has_mysql_create_table_marker( $tokens ); + } + + /** + * Check whether a CREATE TABLE query contains MySQL install-schema syntax. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the query should use the install DDL translator. + */ + private function has_mysql_create_table_marker( array $tokens ): bool { + foreach ( $tokens as $token ) { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a MySQL runtime setting is intentionally ignored. + * + * WordPress PHPUnit bootstrap emits MySQL-only SET statements before schema + * installation. PostgreSQL has no equivalent state for these settings, so they + * should not be sent to PDO. Keep this intentionally narrow so unsupported SET + * statements still fail visibly. + * + * @param string $query MySQL query. + * @return bool Whether the query should be treated as a successful no-op. + */ + private function is_noop_mysql_runtime_setting( string $query ): bool { + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $position = 1; + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::SESSION_SYMBOL, + ), + true + ) + ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::IDENTIFIER !== $tokens[ $position ]->id ) { + return false; + } + + $variable = strtolower( $tokens[ $position ]->get_value() ); + if ( + ! in_array( + $variable, + array( + 'default_storage_engine', + 'foreign_key_checks', + 'sql_mode', + 'storage_engine', + ), + true + ) + ) { + return false; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + $has_value = false; + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + if ( WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + return false; + } + + $has_value = true; + ++$position; + } + + return $has_value && isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF === $tokens[ $position ]->id; + } + /** * Read the backend server version without requiring a PostgreSQL-only 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 index 5555f44df..35280c711 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -121,6 +121,55 @@ public function test_transaction_alias_and_commit_delegate_to_pdo(): void { $this->assertSame( 'first', $rows[0]->value ); } + /** + * Tests MySQL-only runtime SET statements are ignored before reaching PDO. + */ + public function test_mysql_runtime_set_statements_are_noops(): void { + $driver = $this->create_driver(); + + $queries = array( + '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 unsupported SET statements are still sent to PDO. + */ + public function test_unsupported_set_statement_still_reaches_backend(): void { + $driver = $this->create_driver(); + + $this->expectException( PDOException::class ); + + $driver->query( 'SET unsupported_setting = 1' ); + } + + /** + * Tests multi-assignment SET statements are not silently ignored. + */ + public function test_multi_assignment_set_statement_still_reaches_backend(): void { + $driver = $this->create_driver(); + + $this->expectException( PDOException::class ); + + $driver->query( 'SET foreign_key_checks = 0, unsupported_setting = 1' ); + } + /** * Creates a PostgreSQL driver backed by an injected in-memory PDO. * From 830347bc2e0327a4f40b6b5dc7c0fad88de6cbf8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 18:33:35 +0000 Subject: [PATCH 023/142] Install PostgreSQL schema during WordPress setup --- .../postgresql/install-functions.php | 141 +++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) 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 index f8d67822f..bba334543 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -7,12 +7,145 @@ if ( ! function_exists( 'postgresql_make_db_current_silent' ) ) { /** - * Placeholder for PostgreSQL schema installation. + * Create WordPress database tables for PostgreSQL. * - * @return bool False until the PostgreSQL backend can translate WordPress - * install DDL into PostgreSQL. + * @return bool True when schema installation succeeds. */ function postgresql_make_db_current_silent() { - return false; + global $wpdb; + + include_once ABSPATH . 'wp-admin/includes/schema.php'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $statements = $translator->translate_schema( wp_get_db_schema() ); + + foreach ( $statements as $statement ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Generated from parsed WordPress schema DDL. + if ( false === $wpdb->query( $statement ) ) { + $message = sprintf( + 'Error occurred while creating PostgreSQL tables or indexes.
Query was: %s
', + var_export( $statement, true ) + ); + $message .= sprintf( 'Error message is: %s', $wpdb->last_error ); + wp_die( $message, 'Database Error!' ); + } + } + + return true; + } +} + +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(); + 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 ); + + 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, + ); } } From 7b2a2b69831a87b56154852f0537f438c89c33ae Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 19:57:14 +0000 Subject: [PATCH 024/142] Integrate PostgreSQL backend lane work --- ...wp-tests-phpunit-native-extension-setup.sh | 5 + .github/workflows/wp-tests-phpunit-run.js | 22 +- composer.json | 8 +- packages/mysql-on-sqlite/composer.json | 2 + ...-wp-postgresql-create-table-translator.php | 7 +- .../postgresql/class-wp-postgresql-driver.php | 1093 +++++++++++++++++ ...stgreSQL_Create_Table_Translator_Tests.php | 23 + .../tests/WP_PostgreSQL_DB_Tests.php | 691 +++++++++++ .../WP_PostgreSQL_Driver_RegExp_Tests.php | 44 + .../tests/WP_PostgreSQL_Driver_Tests.php | 439 +++++++ .../WP_PostgreSQL_Install_Functions_Tests.php | 332 +++++ packages/mysql-proxy/composer.json | 2 + .../mysql-proxy/tests/WP_MySQL_Proxy_Test.php | 29 + .../postgresql/class-wp-postgresql-db.php | 12 +- wp-setup.sh | 46 +- 15 files changed, 2748 insertions(+), 7 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php 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 5276139a5..d891ec692 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -265,7 +265,7 @@ function ensureWordPressTestEnvironment() { function ensurePostgreSqlWordPressTestEnvironment() { execSync( - 'cd wordpress && if [ -z "$(node tools/local-env/scripts/docker.js ps -q)" ]; then npm run env:start && npm run env:install; fi', + 'cd wordpress && npm run env:start && npm run env:install', { env: { ...process.env, @@ -402,6 +402,11 @@ function validateGeneratedBackendFiles() { 'docker-php-ext-install pdo_pgsql', 'PostgreSQL CLI Dockerfile installs pdo_pgsql' ); + assertFileContains( + installScript, + "const { existsSync, renameSync, readFileSync, writeFileSync } = require( 'fs' );", + 'install.js imports guarded wp-config file helpers' + ); assertFileContains( installScript, '--dbhost=postgres', @@ -412,6 +417,21 @@ function validateGeneratedBackendFiles() { '--skip-check', 'install.js skips MySQL-style connection checks while creating PostgreSQL wp-config.php' ); + assertFileContains( + installScript, + "if ( existsSync( 'src/wp-config.php' ) ) {", + 'install.js guards moving generated src/wp-config.php' + ); + assertFileContains( + installScript, + "if ( ! existsSync( 'wp-config.php' ) ) {", + 'install.js checks that wp-config.php was generated' + ); + assertFileContains( + installScript, + 'wp-config.php was not generated.', + 'install.js reports a missing generated wp-config.php' + ); assertFileContains( installScript, "config set DB_ENGINE postgresql", diff --git a/composer.json b/composer.json index 689b44ed7..a7687f494 100644 --- a/composer.json +++ b/composer.json @@ -54,15 +54,19 @@ "npm --prefix wordpress run" ], "wp-test-start": [ + "@wp-test-ensure-backend @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 ) { 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 }` ] ] ); } if ( \"postgresql\" === backend ) { checks.push( [ \"wordpress/tools/local-env/Dockerfile.postgresql-php\", [ \"docker-php-ext-install pdo_pgsql\" ] ], [ \"wordpress/tools/local-env/Dockerfile.postgresql-cli\", [ \"docker-php-ext-install pdo_pgsql\" ] ], [ \"wordpress/tools/local-env/scripts/install.js\", [ \"--dbhost=postgres\", \"--skip-check\", \"DB_ENGINE postgresql\", \"DATABASE_ENGINE postgresql\", `const { existsSync, renameSync, readFileSync, writeFileSync } = require( ${ quote }fs${ quote } );`, `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); 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", "@putenv COMPOSE_IGNORE_ORPHANS=true", - "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" + "npm --prefix wordpress run env:start", + "npm --prefix wordpress run env:install" ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", diff --git a/packages/mysql-on-sqlite/composer.json b/packages/mysql-on-sqlite/composer.json index 9d2b148fa..bc672218c 100644 --- a/packages/mysql-on-sqlite/composer.json +++ b/packages/mysql-on-sqlite/composer.json @@ -1,6 +1,8 @@ { "name": "wordpress/mysql-on-sqlite", + "description": "A MySQL emulation layer on top of SQLite with a PDO-compatible API.", "type": "library", + "license": "GPL-2.0-or-later", "scripts": { "test": "phpunit" }, 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 index 6cc220380..8e2e27255 100644 --- 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 @@ -342,12 +342,17 @@ private function get_identifier_value( ?WP_Parser_Node $node ): string { throw new InvalidArgumentException( 'Expected identifier node.' ); } - foreach ( $node->get_descendant_tokens() as $token ) { + $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.' ); } 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 index 7562a5e50..d803296b2 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -155,6 +155,41 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo ); } + $describe_table_name = $this->get_describe_table_name( $query ); + if ( null !== $describe_table_name ) { + return $this->execute_describe_query( $describe_table_name, $fetch_mode, ...$fetch_mode_args ); + } + + $translated_query = $this->translate_wordpress_options_regexp_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + + $translated_query = $this->translate_simple_mysql_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + + $translated_query = $this->translate_wordpress_options_upsert_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + + $translated_query = $this->translate_simple_mysql_insert_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + + $translated_query = $this->translate_simple_mysql_update_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + + $translated_query = $this->translate_simple_mysql_select_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + $stmt = $this->connection->query( $query ); $this->last_postgresql_queries[] = array( 'sql' => $query, @@ -192,6 +227,120 @@ private function execute_postgresql_statements( array $statements ) { return $this->last_result; } + /** + * Get the table name from a supported MySQL DESCRIBE/DESC statement. + * + * @param string $query MySQL query. + * @return string|null Table name, or null when the statement is unsupported. + */ + private function get_describe_table_name( string $query ): ?string { + $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; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); + if ( null === $table_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { + return null; + } + + return $table_name; + } + + /** + * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. + * + * @param string $table_name Table name. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed DESCRIBE result rows. + */ + private function execute_describe_query( string $table_name, $fetch_mode, ...$fetch_mode_args ) { + $sql = $this->get_describe_catalog_query(); + $params = array( 'public', $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + + /** + * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. + * + * @return string SQL query. + */ + private function get_describe_catalog_query(): string { + return 'SELECT + c.column_name AS "Field", + CASE + WHEN c.data_type = \'character varying\' THEN + \'varchar\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'character\' THEN + \'char\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'integer\' THEN \'int\' + WHEN c.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE c.data_type + END AS "Type", + c.is_nullable AS "Null", + CASE + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'PRIMARY KEY\' + AND kcu.column_name = c.column_name + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'UNIQUE\' + AND kcu.column_name = c.column_name + ) THEN \'UNI\' + ELSE \'\' + END AS "Key", + c.column_default AS "Default", + CASE + WHEN c.is_identity = \'YES\' THEN \'auto_increment\' + WHEN c.column_default LIKE \'nextval(%\' THEN \'auto_increment\' + ELSE \'\' + END AS "Extra" +FROM information_schema.columns c +WHERE c.table_schema = ? + AND c.table_name = ? +ORDER BY c.ordinal_position'; + } + /** * Get results of the last query. * @@ -268,6 +417,949 @@ private function reset_query_state(): void { $this->last_postgresql_queries = array(); } + /** + * Translate the WordPress options cleanup DELETE ... REGEXP query. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_wordpress_options_regexp_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3], $tokens[4], $tokens[5], $tokens[6] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id + || WP_MySQL_Lexer::REGEXP_SYMBOL !== $tokens[5]->id + ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[2] ); + $column = $this->get_mysql_identifier_token_value( $tokens[4] ); + if ( null === $table_name || null === $column || ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[6]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[6]->id + ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, 7 ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s WHERE %s ~ %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column ), + $this->connection->quote( $tokens[6]->get_value() ) + ); + } + + /** + * Translate simple single-table MySQL DELETE statements to PostgreSQL. + * + * WordPress option deletes emit a single target table and a plain WHERE + * clause. Multi-table DELETE variants and MySQL-only ORDER/LIMIT forms fall + * through unchanged so unsupported SQL still fails visibly in the backend. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id + ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[2] ); + if ( null === $table_name ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 4 ); + if ( null === $statement_end || 4 >= $statement_end ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, $unsupported_tokens ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s WHERE %s', + $this->connection->quote_identifier( $table_name ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 4, $statement_end ) + ); + } + + /** + * Translate WordPress options INSERT ... ON DUPLICATE KEY UPDATE queries. + * + * WordPress installation upserts rows into the options table through MySQL's + * ON DUPLICATE KEY syntax. Keep this intentionally narrow: prefixed options + * tables conflict on option_name, and update assignments must be + * "column = VALUES(column)" so unsupported INSERT shapes still reach PDO. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_wordpress_options_upsert_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name || ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + ++$position; + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = true; + } + + if ( ! isset( $column_lookup['option_name'] ) ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $values_start = $position; + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position + 1 ); + if ( null === $on_duplicate ) { + return null; + } + + $values_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $values_start, $on_duplicate ); + $position = $on_duplicate + 4; + + $assignments = $this->parse_upsert_update_assignments( $tokens, $position, $column_lookup ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return sprintf( + 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + $values_sql, + $this->connection->quote_identifier( 'option_name' ), + implode( ', ', $assignments ) + ); + } + + /** + * Translate simple single-row MySQL INSERT statements to PostgreSQL. + * + * WordPress CRUD helpers emit a narrow INSERT INTO table (columns) VALUES + * (...) shape. MySQL-specific modifiers, INSERT ... SELECT/SET, missing + * column lists, multi-row values, and trailing clauses fall through unchanged. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_insert_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $values_start = $position; + ++$position; + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $values_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $values_end || $values_end !== $statement_end ) { + return null; + } + + return sprintf( + 'INSERT INTO %s (%s) %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $values_start, $values_end ) + ); + } + + /** + * Translate simple single-table MySQL UPDATE statements to PostgreSQL. + * + * WordPress CRUD updates emit a narrow MySQL shape with one table, backticked + * identifiers, and plain SET/WHERE clauses. More complex UPDATE syntax falls + * through unchanged so unsupported SQL still fails visibly in the backend. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_update_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $position, $statement_end ); + $set_end = $where_position ?? $statement_end; + if ( ! $this->is_supported_simple_update_set_clause( $tokens, $position, $set_end ) ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, $position, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $sql = sprintf( + 'UPDATE %s SET %s', + $this->connection->quote_identifier( $table_name ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $set_end ) + ); + + if ( null !== $where_position ) { + if ( $where_position + 1 >= $statement_end ) { + return null; + } + + $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $statement_end + ); + } + + return $sql; + } + + /** + * Translate simple single-table MySQL SELECT statements to PostgreSQL. + * + * This intentionally covers only the WordPress read shapes that need + * identifier quoting for PostgreSQL. Joins, grouping, limits, subqueries, + * functions, aliases, and MySQL-only SELECT modifiers fall through unchanged. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $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; + } + + if ( ! $this->is_supported_simple_select_projection( $tokens, 1, $from_position ) ) { + return null; + } + + $table_token = $tokens[ $from_position + 1 ] ?? null; + $table_name = $this->get_mysql_identifier_token_value( $table_token ); + if ( null === $table_name ) { + return null; + } + + $position = $from_position + 2; + $where_position = null; + $where_end = null; + $order_position = null; + + if ( $position < $statement_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, $statement_end ); + $where_end = $order_position ?? $statement_end; + + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $position = $where_end; + } + + if ( $position < $statement_end && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + $order_position = $position; + if ( ! $this->is_supported_simple_select_order_by_clause( $tokens, $order_position, $statement_end ) ) { + return null; + } + + $position = $statement_end; + } + + if ( $position !== $statement_end ) { + return null; + } + + $sql = sprintf( + 'SELECT %s FROM %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 1, $from_position ), + $this->translate_mysql_identifier_token_to_postgresql( $table_token ) + ); + + if ( null !== $where_position ) { + $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end + ); + } + + if ( null !== $order_position ) { + $sql .= ' ORDER BY ' . $this->translate_mysql_token_to_postgresql( $tokens[ $order_position + 2 ] ); + if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $statement_end ) { + $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); + } + } + + return $sql; + } + + /** + * Tokenize a MySQL query with the configured lexer implementation. + * + * @param string $query MySQL query. + * @return WP_MySQL_Token[] MySQL lexer token stream. + */ + private function get_mysql_tokens( string $query ): array { + $lexer = new WP_MySQL_Lexer( $query ); + return $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + } + + /** + * Check whether a table name is a WordPress options table. + * + * @param string $table_name Table identifier value. + * @return bool Whether the table is an options table. + */ + private function is_wordpress_options_table_name( string $table_name ): bool { + $table_name = strtolower( $table_name ); + return 'options' === $table_name || '_options' === substr( $table_name, -8 ); + } + + /** + * Parse a parenthesized MySQL identifier list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string[]|null Identifier values, or null when unsupported. + */ + private function parse_mysql_identifier_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $identifiers = array(); + + while ( isset( $tokens[ $position ] ) ) { + $identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $identifiers; + } + + return null; + } + + return null; + } + + /** + * Locate the top-level ON DUPLICATE KEY UPDATE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Token position where scanning starts. + * @return int|null Token position of ON, or null when not found. + */ + private function find_on_duplicate_key_update_clause( array $tokens, int $position ): ?int { + $depth = 0; + for ( $i = $position; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( + 0 === $depth + && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 3 ] ) + && WP_MySQL_Lexer::DUPLICATE_SYMBOL === $tokens[ $i + 1 ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $i + 2 ]->id + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $i + 3 ]->id + ) { + return $i; + } + } + + return null; + } + + /** + * Parse ON DUPLICATE KEY UPDATE assignments for the supported upsert shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $column_lookup Insert-column lookup by lowercase name. + * @return string[]|null PostgreSQL SET assignments, or null when unsupported. + */ + private function parse_upsert_update_assignments( array $tokens, int &$position, array $column_lookup ): ?array { + $assignments = array(); + + while ( isset( $tokens[ $position ] ) ) { + $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); + if ( null === $target_column ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $source_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 2 ] ); + if ( + null === $source_column + || strtolower( $source_column ) !== strtolower( $target_column ) + || ! isset( $column_lookup[ strtolower( $source_column ) ] ) + ) { + return null; + } + + $assignments[] = sprintf( + '%s = excluded.%s', + $this->connection->quote_identifier( $target_column ), + $this->connection->quote_identifier( $source_column ) + ); + $position += 4; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + return count( $assignments ) > 0 ? $assignments : null; + } + + /** + * Validate the narrow SET clause supported by the simple UPDATE translator. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @return bool Whether the SET clause is supported. + */ + private function is_supported_simple_update_set_clause( array $tokens, int $start, int $end ): bool { + return $start + 2 < $end + && null !== $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ) + && isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $start + 1 ]->id; + } + + /** + * Validate a simple SELECT projection list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the projection is supported. + */ + private function is_supported_simple_select_projection( array $tokens, int $start, int $end ): bool { + if ( $start + 1 === $end && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start ]->id ) { + return true; + } + + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $i ] ?? null ) ) { + return false; + } + + ++$i; + if ( $i >= $end ) { + return true; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { + return false; + } + } + + return false; + } + + /** + * Validate the simple expression fragments used by translated DML/SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First fragment token position. + * @param int $end Final fragment token position, exclusive. + * @return bool Whether the expression fragment is supported. + */ + private function is_supported_simple_mysql_expression_fragment( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $i ] ) ) { + return false; + } + } + + return true; + } + + /** + * Validate a token for a simple expression fragment. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is supported. + */ + private function is_supported_simple_mysql_expression_token( WP_MySQL_Token $token ): bool { + if ( null !== $this->get_mysql_identifier_token_value( $token ) ) { + return true; + } + + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::FALSE_SYMBOL, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + + /** + * Validate a simple ORDER BY clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @return bool Whether the ORDER BY clause is supported. + */ + private function is_supported_simple_select_order_by_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $start + 1 ]->id + || null === $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ) + ) { + return false; + } + + if ( $start + 3 === $end ) { + return true; + } + + return $start + 4 === $end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $start + 3 ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $start + 3 ]->id + ); + } + + /** + * Find the token position ending a single MySQL statement. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Token position where scanning starts. + * @return int|null EOF or semicolon token position, or null for multi-statements. + */ + private function get_mysql_statement_end_position( array $tokens, int $position ): ?int { + for ( $i = $position; isset( $tokens[ $i ] ); $i++ ) { + if ( WP_MySQL_Lexer::EOF === $tokens[ $i ]->id ) { + return $i; + } + + if ( WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $i ]->id ) { + return $this->is_at_mysql_query_end( $tokens, $i ) ? $i : null; + } + } + + return null; + } + + /** + * Find the position after a matching parenthesized token sequence. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Opening parenthesis position. + * @param int $limit Final token position, exclusive. + * @return int|null Position after the matching close parenthesis, or null. + */ + private function get_mysql_parenthesized_sequence_end( array $tokens, int $position, int $limit ): ?int { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $depth = 0; + for ( $i = $position; $i < $limit; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $i ]->id ) { + continue; + } + + --$depth; + if ( 0 === $depth ) { + return $i + 1; + } + + if ( $depth < 0 ) { + return null; + } + } + + return null; + } + + /** + * Find a top-level MySQL token in a bounded token range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $token_id Token ID to find. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return int|null Token position, or null when not found. + */ + private function find_top_level_mysql_token( array $tokens, int $token_id, int $start, int $end ): ?int { + $depth = 0; + + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && $token_id === $tokens[ $i ]->id ) { + return $i; + } + } + + return null; + } + + /** + * Check whether a bounded token range contains any top-level token IDs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @param int[] $token_ids Token IDs to detect. + * @return bool Whether any token ID was found. + */ + private function contains_top_level_mysql_token( array $tokens, int $start, int $end, array $token_ids ): bool { + foreach ( $token_ids as $token_id ) { + if ( null !== $this->find_top_level_mysql_token( $tokens, $token_id, $start, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether the token position is at the end of a single query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether only an optional semicolon and EOF remain. + */ + private function is_at_mysql_query_end( array $tokens, int $position ): bool { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF === $tokens[ $position ]->id; + } + + /** + * Translate a MySQL token sequence to PostgreSQL SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_to_postgresql( array $tokens, int $start, int $end ): string { + $sql = ''; + $previous_token_id = null; + + for ( $i = $start; $i < $end; $i++ ) { + $token = $tokens[ $i ]; + $fragment = $this->translate_mysql_token_to_postgresql( $token ); + + if ( '' === $sql ) { + $sql = $fragment; + } elseif ( $this->should_join_mysql_tokens_without_space( $previous_token_id, $token->id ) ) { + $sql .= $fragment; + } else { + $sql .= ' ' . $fragment; + } + + $previous_token_id = $token->id; + } + + return $sql; + } + + /** + * Translate a single MySQL token to a PostgreSQL fragment. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_to_postgresql( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id && $this->should_quote_bare_mysql_identifier( $token->get_value() ) ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + + return $token->get_bytes(); + } + + /** + * Translate a MySQL identifier token to a PostgreSQL identifier fragment. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string PostgreSQL identifier fragment. + */ + private function translate_mysql_identifier_token_to_postgresql( ?WP_MySQL_Token $token ): string { + if ( null === $token ) { + return ''; + } + + return $this->translate_mysql_token_to_postgresql( $token ); + } + + /** + * Check whether two translated tokens should be joined without a space. + * + * @param int|null $previous_token_id Previous token ID. + * @param int $token_id Current token ID. + * @return bool Whether no separator should be added. + */ + private function should_join_mysql_tokens_without_space( ?int $previous_token_id, int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) || in_array( + $previous_token_id, + array( + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + ), + true + ); + } + + /** + * Get a MySQL identifier token value. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null when the token is unsupported. + */ + private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token ): ?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(); + } + + return null; + } + + /** + * Check whether a bare MySQL identifier needs PostgreSQL quoting. + * + * @param string $identifier Identifier token value. + * @return bool Whether the bare identifier must be quoted. + */ + private function should_quote_bare_mysql_identifier( string $identifier ): bool { + return strtolower( $identifier ) !== $identifier; + } + /** * Check whether a query is a supported MySQL CREATE TABLE statement. * @@ -362,6 +1454,7 @@ private function is_noop_mysql_runtime_setting( string $query ): bool { ! in_array( $variable, array( + 'autocommit', 'default_storage_engine', 'foreign_key_checks', 'sql_mode', 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 index 1e262375e..dd390abe6 100644 --- 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 @@ -51,6 +51,29 @@ public function test_translate_wp_term_relationships_create_table(): void { ); } + /** + * Tests WordPress schema identifiers that are tokenized as MySQL keywords. + */ + public function test_translate_wordpress_keyword_identifier_columns(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_keyword_identifiers\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"name\" varchar(200) NOT NULL DEFAULT '',\n \"description\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_keyword_identifiers__name" ON "wp_keyword_identifiers" ("name")', + 'CREATE INDEX "wp_keyword_identifiers__description" ON "wp_keyword_identifiers" ("description")', + ), + $this->translate( + "CREATE TABLE wp_keyword_identifiers ( + id bigint(20) unsigned NOT NULL auto_increment, + name varchar(200) NOT NULL default '', + description longtext NOT NULL, + PRIMARY KEY (id), + KEY name (name(191)), + KEY description (description(191)) + ) DEFAULT CHARACTER SET utf8mb4" + ) + ); + } + /** * Tests MySQL prefix index lengths are stripped from PostgreSQL index columns. */ 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..19f801de2 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -0,0 +1,691 @@ +run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$capabilities = array(); +foreach ( + array( + 'collation', + 'group_concat', + 'subqueries', + 'identifier_placeholders', + 'COLLATION', + 'GROUP_CONCAT', + 'SUBQUERIES', + 'IDENTIFIER_PLACEHOLDERS', + 'set_charset', + 'SET_CHARSET', + ) as $capability +) { + $capabilities[ $capability ] = $db->has_cap( $capability ); +} + +wp_postgresql_db_test_respond( + array( + 'db_version' => $db->db_version(), + 'capabilities' => $capabilities, + ) +); +PHP + ); + + $this->assertSame( + array( + 'collation' => true, + 'group_concat' => true, + 'subqueries' => true, + 'identifier_placeholders' => true, + 'COLLATION' => true, + 'GROUP_CONCAT' => true, + 'SUBQUERIES' => true, + 'IDENTIFIER_PLACEHOLDERS' => true, + 'set_charset' => version_compare( $result['db_version'], '5.0.7', '>=' ), + 'SET_CHARSET' => version_compare( $result['db_version'], '5.0.7', '>=' ), + ), + $result['capabilities'] + ); + } + + /** + * Tests query state, metadata, and SAVEQUERIES mapping. + */ + public function test_query_maps_backend_state_to_wpdb_fields(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +define( 'SAVEQUERIES', true ); + +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + public $time_start = 0; + + public function timer_start() { + $this->time_start = microtime( true ); + } + + public function timer_stop() { + return microtime( true ) - $this->time_start; + } + + public function get_caller() { + return 'wpdb-test'; + } + + public function log_query( $query, $elapsed, $caller, $start, $data ) { + $this->queries[] = array( + 'query' => $query, + 'elapsed' => $elapsed, + 'caller' => $caller, + 'start' => $start, + 'data' => $data, + ); + } + + public function add_placeholder_escape( $query ) { + return $query; + } + + public function get_col_info( $info_type = 'name', $col_offset = -1 ) { + $this->load_col_info(); + + if ( -1 === $col_offset ) { + return array_map( + static function ( $column ) use ( $info_type ) { + return $column->{$info_type}; + }, + $this->col_info + ); + } + + return $this->col_info[ $col_offset ]->{$info_type} ?? null; + } + + protected function load_col_info() {} +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Fake_Driver extends WP_PostgreSQL_Driver { + private $last_return_value = 0; + private $insert_id = 0; + private $last_postgresql_queries = array(); + private $last_column_meta = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->last_postgresql_queries = array( + array( + 'sql' => $query, + 'params' => array(), + ), + ); + + if ( false !== strpos( $query, 'broken' ) ) { + throw new RuntimeException( 'synthetic backend failure' ); + } + + if ( 0 === stripos( $query, 'insert' ) ) { + $this->last_return_value = 1; + $this->insert_id = 7; + $this->last_column_meta = array(); + return 1; + } + + $this->last_return_value = 0; + $this->insert_id = 0; + $this->last_column_meta = array( + array( + 'name' => 'id', + 'mysqli:orgname' => 'id', + 'table' => 'probe', + 'mysqli:orgtable' => 'probe', + 'mysqli:db' => 'wptests', + 'len' => 11, + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 1, + 'mysqli:type' => 3, + 'precision' => 0, + ), + array( + 'name' => 'label', + 'mysqli:orgname' => 'label', + 'table' => 'probe', + 'mysqli:orgtable' => 'probe', + 'mysqli:db' => 'wptests', + 'len' => 255, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'precision' => 0, + ), + ); + + return array( + (object) array( + 'id' => '1', + 'label' => 'ok', + ), + ); + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return $this->last_postgresql_queries; + } + + public function get_last_column_meta(): array { + return $this->last_column_meta; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, new WP_PostgreSQL_DB_Fake_Driver() ); +$db->ready = true; + +$select_return = $db->query( 'SELECT id, label FROM probe' ); +$select = array( + 'return' => $select_return, + 'num_rows' => $db->num_rows, + 'last_result_label' => $db->last_result[0]->label ?? null, + 'col_names' => $db->get_col_info( 'name' ), + 'first_col_type' => $db->get_col_info( 'type', 0 ), + 'savequeries_pg_sql' => $db->queries[0]['postgresql_queries'][0]['sql'] ?? null, +); + +$insert_return = $db->query( "INSERT INTO probe (label) VALUES ('ok')" ); +$insert = array( + 'return' => $insert_return, + 'rows_affected' => $db->rows_affected, + 'insert_id' => $db->insert_id, +); + +$failed_return = $db->query( "INSERT INTO broken (label) VALUES ('bad')" ); +$failed_insert = array( + 'return' => $failed_return, + 'last_error' => $db->last_error, + 'insert_id' => $db->insert_id, +); + +wp_postgresql_db_test_respond( + array( + 'select' => $select, + 'insert' => $insert, + 'failed_insert' => $failed_insert, + ) +); +PHP + ); + + $this->assertSame( + array( + 'return' => 1, + 'num_rows' => 1, + 'last_result_label' => 'ok', + 'col_names' => array( 'id', 'label' ), + 'first_col_type' => 3, + 'savequeries_pg_sql' => 'SELECT id, label FROM probe', + ), + $result['select'] + ); + + $this->assertSame( + array( + 'return' => 1, + 'rows_affected' => 1, + 'insert_id' => 7, + ), + $result['insert'] + ); + + $this->assertSame( + array( + 'return' => false, + 'last_error' => 'synthetic backend failure', + 'insert_id' => 0, + ), + $result['failed_insert'] + ); + } + + /** + * Tests the SQL generated by real wpdb helper methods before the driver sees it. + */ + public function test_real_wpdb_update_and_delete_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Helper_SQL_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } +} + +class WP_PostgreSQL_DB_Helper_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + private $queries = array(); + private $last_return_value = 0; + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Helper_SQL_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + $this->last_return_value = 1; + + return 1; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Helper_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$update_return = $db->update( + 'wptests_options', + array( + 'option_value' => 'Site Name', + ), + array( + 'option_name' => 'blogname', + ) +); +$delete_return = $db->delete( + 'wptests_options', + array( + 'option_name' => 'temporary', + ) +); + +wp_postgresql_db_test_respond( + array( + 'loaded_wpdb' => class_exists( 'wpdb', false ), + 'update_return' => $update_return, + 'delete_return' => $delete_return, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['loaded_wpdb'] ); + $this->assertSame( 1, $result['update_return'] ); + $this->assertSame( 1, $result['delete_return'] ); + $this->assertSame( + array( + "UPDATE `wptests_options` SET `option_value` = 'Site Name' WHERE `option_name` = 'blogname'", + "DELETE FROM `wptests_options` WHERE `option_name` = 'temporary'", + ), + $result['queries'] + ); + } + + /** + * Tests the SQL sent by real wpdb read helpers before the driver sees it. + */ + public function test_real_wpdb_read_helpers_pass_identifier_select_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Read_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( false !== strpos( $query, 'wptests_users' ) ) { + return array( + (object) array( + 'ID' => '1', + 'user_login' => 'admin', + ), + ); + } + + if ( 0 === strpos( $query, 'SELECT `option_value`' ) ) { + return array( + (object) array( + 'option_value' => 'http://example.org', + ), + ); + } + + if ( 0 === strpos( $query, 'SELECT `option_name` FROM' ) ) { + return array( + (object) array( + 'option_name' => 'siteurl', + ), + ); + } + + return array( + (object) array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ), + ); + } + + public function get_last_return_value() { + return 0; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Read_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$check_current_query_property = new ReflectionProperty( 'wpdb', 'check_current_query' ); +$check_current_query_property->setAccessible( true ); +$check_current_query_property->setValue( $db, false ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$option_value = $db->get_var( "SELECT `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'" ); +$option_row = $db->get_row( "SELECT `option_name`, `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", ARRAY_A ); +$option_rows = $db->get_results( 'SELECT `option_name` FROM `wptests_options` ORDER BY `option_name`', ARRAY_A ); +$user_row = $db->get_row( 'SELECT ID, user_login FROM wptests_users WHERE ID = 1', ARRAY_A ); + +wp_postgresql_db_test_respond( + array( + 'option_value' => $option_value, + 'option_row' => $option_row, + 'option_rows' => $option_rows, + 'user_row' => $user_row, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertSame( 'http://example.org', $result['option_value'] ); + $this->assertSame( + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ), + $result['option_row'] + ); + $this->assertSame( + array( + array( + 'option_name' => 'siteurl', + ), + ), + $result['option_rows'] + ); + $this->assertSame( + array( + 'ID' => '1', + 'user_login' => 'admin', + ), + $result['user_row'] + ); + $this->assertSame( + array( + "SELECT `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", + "SELECT `option_name`, `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", + 'SELECT `option_name` FROM `wptests_options` ORDER BY `option_name`', + 'SELECT ID, user_login FROM wptests_users WHERE ID = 1', + ), + $result['queries'] + ); + } + + /** + * Runs a PostgreSQL wpdb script in a separate PHP process. + * + * @param string $script Script body without the opening PHP tag. + * @return array Decoded JSON response from the script. + */ + private function run_isolated_wpdb_script( string $script ): array { + $script_file = tempnam( sys_get_temp_dir(), 'wp_pg_db_' ); + if ( false === $script_file ) { + $this->fail( 'Could not create temporary PostgreSQL wpdb test script.' ); + } + + $script_written = file_put_contents( + $script_file, + "get_isolated_script_prelude() . "\n" . $script + ); + if ( false === $script_written ) { + unlink( $script_file ); + $this->fail( 'Could not write temporary PostgreSQL wpdb test script.' ); + } + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + $process = proc_open( + escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script_file ), + $descriptor_spec, + $pipes, + __DIR__ + ); + + if ( ! is_resource( $process ) ) { + unlink( $script_file ); + $this->fail( 'Could not start isolated PostgreSQL wpdb test process.' ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + $exitcode = proc_close( $process ); + unlink( $script_file ); + + $this->assertSame( + 0, + $exitcode, + "Isolated PostgreSQL wpdb script failed.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + $decoded = json_decode( $stdout, true ); + $this->assertIsArray( + $decoded, + "Isolated PostgreSQL wpdb script did not return JSON.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + return $decoded; + } + + /** + * Gets helper code prepended to every isolated script. + * + * @return string PHP script body. + */ + private function get_isolated_script_prelude(): string { + return <<<'PHP' +function wp_postgresql_db_test_respond( array $payload ) { + echo json_encode( $payload ); +} +PHP; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php new file mode 100644 index 000000000..9c44e3f8d --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -0,0 +1,44 @@ + new PDO( 'sqlite::memory:' ) ) ); + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$logged_queries ): void { + $logged_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + ); + + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $query = "DELETE FROM `wptests_options` WHERE `option_name` REGEXP '^_transient_feed_'"; + + try { + $driver->query( $query ); + $this->fail( 'SQLite unexpectedly accepted the PostgreSQL regular expression operator.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'near "~"', $exception->getMessage() ); + } + + $this->assertSame( $query, $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( + 'sql' => 'DELETE FROM "wptests_options" WHERE "option_name" ~ \'^_transient_feed_\'', + 'params' => array(), + ), + end( $logged_queries ) + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 35280c711..6d9a6b761 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -51,6 +51,108 @@ public function test_write_query_returns_row_count(): void { $this->assertSame( 0, $driver->get_last_column_count() ); } + /** + * Tests simple WordPress INSERT statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_insert_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users (user_login TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_users` (`user_login`) VALUES ('admin')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( $insert, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_users" ("user_login") VALUES (\'admin\')', $queries[0]['sql'] ); + $this->assertSame( array(), $queries[0]['params'] ); + + $rows = $driver->query( "SELECT user_login FROM wptests_users WHERE user_login = 'admin'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'admin', $rows[0]->user_login ); + } + + /** + * Tests 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 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 successive queries reset result metadata and backend query logs. */ @@ -121,6 +223,275 @@ public function test_transaction_alias_and_commit_delegate_to_pdo(): void { $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(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_id INTEGER PRIMARY KEY AUTOINCREMENT, + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL, + autoload TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( $insert, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wp_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $update = "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.net', 'no') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wp_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wp_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 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\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value FROM wp_options WHERE option_name = 'key1'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'value2', $rows[0]->option_value ); + } + + /** + * Tests simple WordPress DELETE statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_delete_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'http://example.org')" ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('home', 'http://example.org')" ); + + $delete = "DELETE FROM `wp_options` WHERE `option_name` = 'siteurl'"; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wp_options" WHERE "option_name" = \'siteurl\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT option_name FROM wp_options ORDER BY option_name' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'home', $rows[0]->option_name ); + } + + /** + * Tests bare uppercase ID in simple DELETE WHERE clauses is quoted. + */ + public function test_simple_delete_with_bare_uppercase_id_where_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'editor\')' ); + + $delete = 'DELETE FROM wptests_users WHERE ID != 1'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_users" WHERE "ID" != 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests UPDATE shapes with top-level commas still reach the backend unchanged. + */ + public function test_unsupported_update_with_comma_still_reaches_backend(): 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, + '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 unsupported ON DUPLICATE KEY INSERT shapes still reach PDO. + */ + public function test_unsupported_options_upsert_still_reaches_backend(): 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 + )' + ); + + $this->expectException( PDOException::class ); + + $driver->query( + "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net'" + ); + } + + /** + * Tests DESCRIBE for a missing table returns an empty catalog result. + */ + public function test_describe_missing_table_returns_empty_result(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'DESCRIBE wptests_missing' ); + + $this->assertSame( array(), $result ); + $this->assertSame( 'DESCRIBE wptests_missing', $driver->get_last_mysql_query() ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'DESCRIBE', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_missing' ), $queries[0]['params'] ); + } + + /** + * Tests DESC returns MySQL-shaped column metadata for an existing table. + */ + public function test_desc_existing_table_returns_mysql_shaped_column_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'DESC `wptests_options`;' ); + + $this->assertCount( 4, $result ); + $this->assertSame( 'DESC `wptests_options`;', $driver->get_last_mysql_query() ); + + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'bigint', $result[0]->Type ); + $this->assertSame( 'NO', $result[0]->Null ); + $this->assertSame( 'PRI', $result[0]->Key ); + $this->assertNull( $result[0]->Default ); + $this->assertSame( 'auto_increment', $result[0]->Extra ); + + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'varchar(191)', $result[1]->Type ); + $this->assertSame( 'NO', $result[1]->Null ); + $this->assertSame( 'UNI', $result[1]->Key ); + $this->assertNull( $result[1]->Default ); + $this->assertSame( '', $result[1]->Extra ); + + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 'text', $result[2]->Type ); + $this->assertSame( '', $result[2]->Key ); + + $this->assertSame( 'autoload', $result[3]->Field ); + $this->assertSame( 'varchar(20)', $result[3]->Type ); + $this->assertSame( "'yes'::character varying", $result[3]->Default ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'DESC `wptests_options`', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + /** * Tests MySQL-only runtime SET statements are ignored before reaching PDO. */ @@ -128,6 +499,8 @@ 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', @@ -179,4 +552,70 @@ private function create_driver(): WP_PostgreSQL_Driver { $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + + /** + * Install a small information_schema fixture into the injected PDO. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_information_schema_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( + 'CREATE TABLE information_schema.columns ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + data_type TEXT NOT NULL, + character_maximum_length INTEGER, + is_nullable TEXT NOT NULL, + column_default TEXT, + is_identity TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.table_constraints ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + constraint_type TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.key_column_usage ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL + )' + ); + + $pdo->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_options', 'option_id', 1, 'bigint', NULL, 'NO', NULL, 'YES'), + ('public', 'wptests_options', 'option_name', 2, 'character varying', 191, 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'option_value', 3, 'text', NULL, 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'autoload', 4, 'character varying', 20, 'NO', '''yes''::character varying', 'NO')" + ); + $pdo->exec( + "INSERT INTO information_schema.table_constraints + (constraint_schema, constraint_name, table_schema, table_name, constraint_type) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'PRIMARY KEY'), + ('public', 'wptests_options_option_name_key', 'public', 'wptests_options', 'UNIQUE')" + ); + $pdo->exec( + "INSERT INTO information_schema.key_column_usage + (constraint_schema, constraint_name, table_schema, table_name, column_name) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'option_id'), + ('public', 'wptests_options_option_name_key', 'public', 'wptests_options', 'option_name')" + ); + } } 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..8edc37873 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php @@ -0,0 +1,332 @@ +run_isolated_install_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'queries[] = $statement; + return true; + } +}; + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + +$result = postgresql_make_db_current_silent(); + +wp_pg_install_test_remove_tree( $root ); +wp_pg_install_test_respond( + array( + 'result' => $result, + 'queries' => $GLOBALS['wpdb']->queries, + ) +); +PHP + ); + + $this->assertTrue( $result['result'] ); + $this->assertSame( + array( + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + 'CREATE INDEX "wp_options__autoload" ON "wp_options" ("autoload")', + ), + $result['queries'] + ); + } + + /** + * Tests wp_install() creates schema before running populate helpers. + */ + public function test_wp_install_creates_schema_before_populating_site(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' +function do_action( $hook, ...$args ) { + wp_pg_install_test_event( array( 'do_action', $hook ) ); +} + +require_once getcwd() . '/bootstrap.php'; + +function wp_pg_install_test_event( $event ) { + $GLOBALS['wp_pg_install_test_events'][] = $event; +} + +function wp_check_mysql_version() { + wp_pg_install_test_event( 'wp_check_mysql_version' ); +} + +function wp_cache_flush() { + wp_pg_install_test_event( 'wp_cache_flush' ); +} + +function populate_options() { + wp_pg_install_test_event( 'populate_options' ); +} + +function populate_roles() { + wp_pg_install_test_event( 'populate_roles' ); +} + +function update_option( $option, $value ) { + wp_pg_install_test_event( array( 'update_option', $option, $value ) ); + return true; +} + +function wp_guess_url() { + wp_pg_install_test_event( 'wp_guess_url' ); + return 'https://example.test'; +} + +function username_exists( $user_name ) { + wp_pg_install_test_event( array( 'username_exists', $user_name ) ); + return false; +} + +function wp_generate_password( $length, $special_chars ) { + wp_pg_install_test_event( array( 'wp_generate_password', $length, $special_chars ) ); + return 'generated-password'; +} + +function __( $text, $domain = null ) { + return $text; +} + +function wp_create_user( $user_name, $password, $user_email ) { + wp_pg_install_test_event( array( 'wp_create_user', $user_name, $password, $user_email ) ); + return 42; +} + +function update_user_meta( $user_id, $meta_key, $meta_value ) { + wp_pg_install_test_event( array( 'update_user_meta', $user_id, $meta_key, $meta_value ) ); + return true; +} + +class WP_User { + public $ID; + public $user_url; + + public function __construct( $user_id ) { + $this->ID = $user_id; + wp_pg_install_test_event( array( 'WP_User::__construct', $user_id ) ); + } + + public function set_role( $role ) { + wp_pg_install_test_event( array( 'WP_User::set_role', $role ) ); + } +} + +function wp_update_user( $user ) { + wp_pg_install_test_event( array( 'wp_update_user', $user->ID, $user->user_url ) ); + return $user->ID; +} + +function wp_install_defaults( $user_id ) { + wp_pg_install_test_event( array( 'wp_install_defaults', $user_id ) ); +} + +function wp_install_maybe_enable_pretty_permalinks() { + wp_pg_install_test_event( 'wp_install_maybe_enable_pretty_permalinks' ); +} + +function flush_rewrite_rules() { + wp_pg_install_test_event( 'flush_rewrite_rules' ); +} + +function wp_new_blog_notification( $blog_title, $guessurl, $user_id, $password ) { + wp_pg_install_test_event( array( 'wp_new_blog_notification', $blog_title, $guessurl, $user_id, $password ) ); +} + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + ' $GLOBALS['wp_pg_install_test_events'], + 'result' => $install_result, + ) +); +PHP + ); + + $this->assertSame( + array( + array( + 'schema_query', + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + ), + array( + 'schema_query', + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + ), + 'populate_options', + 'populate_roles', + ), + array_slice( $result['events'], 2, 4 ) + ); + $this->assertSame( 42, $result['result']['user_id'] ); + $this->assertSame( 'secret-password', $result['result']['password'] ); + } + + /** + * 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-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/tests/WP_MySQL_Proxy_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php index b4504a030..103ed4ecc 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -1,6 +1,7 @@ skip_if_missing_test_client(); + $this->server = new MySQL_Server_Process( array( 'port' => $this->port, @@ -19,6 +22,10 @@ public function setUp(): void { } public function tearDown(): void { + if ( ! $this->server instanceof MySQL_Server_Process ) { + return; + } + $this->server->stop(); $exit_code = $this->server->get_exit_code(); if ( $this->hasFailed() || ( $exit_code > 0 && 143 !== $exit_code ) ) { @@ -32,4 +39,26 @@ public function tearDown(): void { ); } } + + private function skip_if_missing_test_client(): void { + switch ( static::class ) { + case WP_MySQL_Proxy_CLI_Test::class: + if ( null === ( new ExecutableFinder() )->find( 'mysql' ) ) { + $this->markTestSkipped( 'The mysql CLI client is not available.' ); + } + break; + + case WP_MySQL_Proxy_MySQLi_Test::class: + if ( ! class_exists( 'mysqli' ) ) { + $this->markTestSkipped( 'The mysqli extension is not available.' ); + } + break; + + case WP_MySQL_Proxy_PDO_Test::class: + if ( ! class_exists( 'PDO' ) || ! in_array( 'mysql', PDO::getAvailableDrivers(), true ) ) { + $this->markTestSkipped( 'The pdo_mysql extension is not available.' ); + } + break; + } + } } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 6d119ae02..bb1e52158 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -392,7 +392,17 @@ public function query( $query ) { * @return bool Whether the database feature is supported. */ public function has_cap( $db_cap ) { - return 'subqueries' === strtolower( $db_cap ); + switch ( strtolower( $db_cap ) ) { + case 'collation': + case 'group_concat': + case 'subqueries': + case 'identifier_placeholders': + return true; + case 'set_charset': + return version_compare( $this->db_version(), '5.0.7', '>=' ); + } + + return false; } /** diff --git a/wp-setup.sh b/wp-setup.sh index fcb81f47f..1c28c142e 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -30,6 +30,14 @@ case "$WP_TEST_DB_BACKEND" in ;; esac +WP_SETUP_LOCK_DIR="$DIR/.wp-setup.lock" +if ! mkdir "$WP_SETUP_LOCK_DIR" 2>/dev/null; then + echo 'Error: Another wp-setup.sh process is already running for this checkout.' >&2 + echo "If no setup process is running, remove '$WP_SETUP_LOCK_DIR' and rerun this command." >&2 + exit 1 +fi +trap 'rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null || true' EXIT + # 1. Ensure that Git is installed. echo "Checking if Git is installed..." if ! command -v git &> /dev/null; then @@ -230,6 +238,12 @@ const fs = require( 'fs' ); const file = process.argv[2]; const replacements = [ + { + from: "const { renameSync, readFileSync, writeFileSync } = require( 'fs' );", + to: [ + "const { existsSync, renameSync, readFileSync, writeFileSync } = require( 'fs' );", + ], + }, { from: "wp_cli( 'config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --path=/var/www/src --force' );", to: [ @@ -244,6 +258,17 @@ const replacements = [ "\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: [ @@ -278,9 +303,26 @@ const replacements = [ }, ]; +const input = fs.readFileSync( file, 'utf8' ).split( '\n' ); +const containsLines = ( lines, expected ) => { + for ( let index = 0; index <= lines.length - expected.length; index++ ) { + let matches = true; + for ( let offset = 0; offset < expected.length; offset++ ) { + if ( lines[ index + offset ] !== expected[ offset ] ) { + matches = false; + break; + } + } + if ( matches ) { + return true; + } + } + return false; +}; + const found = new Set(); const output = []; -for ( const line of fs.readFileSync( file, 'utf8' ).split( '\n' ) ) { +for ( const line of input ) { const replacement = replacements.find( candidate => candidate.from === line ); if ( replacement ) { found.add( replacement.from ); @@ -291,7 +333,7 @@ for ( const line of fs.readFileSync( file, 'utf8' ).split( '\n' ) ) { } for ( const replacement of replacements ) { - if ( ! found.has( replacement.from ) ) { + if ( ! found.has( replacement.from ) && ! containsLines( input, replacement.to ) ) { throw new Error( `Expected line not found in ${ file }: ${ replacement.from }` ); } } From 35246810bb37fc0049696e7d3e1da47bbdaa0223 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 19:58:20 +0000 Subject: [PATCH 025/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 19:58:20 UTC. --- .../tests/WP_PostgreSQL_DB_Tests.php | 85 ++++++- .../postgresql/class-wp-postgresql-db.php | 211 +++++++++++++++++- 2 files changed, 294 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 19f801de2..4ae768171 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -12,7 +12,7 @@ class WP_PostgreSQL_DB_Tests extends TestCase { public function test_has_cap_matches_wordpress_db_expectations(): void { $result = $this->run_isolated_wpdb_script( <<<'PHP' -require_once getcwd() . '/bootstrap.php'; + require_once getcwd() . '/bootstrap.php'; class wpdb {} @@ -64,6 +64,89 @@ class wpdb {} ); } + /** + * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. + */ + public function test_real_wpdb_prepare_identifier_placeholders_use_postgresql_quotes(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' + require_once getcwd() . '/bootstrap.php'; + + function wp_load_translations_early() {} + function __( $text ) { + return $text; + } + function _doing_it_wrong() {} + function has_filter() { + return false; + } + function add_filter() { + return true; + } + + require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + + class WP_PostgreSQL_DB_Prepare_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + } + + class WP_PostgreSQL_DB_Prepare_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Prepare_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + } + + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + $driver_property->setAccessible( true ); + $driver_property->setValue( $db, new WP_PostgreSQL_DB_Prepare_Fake_Driver() ); + + $db->charset = 'utf8mb4'; + + wp_postgresql_db_test_respond( + array( + 'has_identifier_cap' => $db->has_cap( 'identifier_placeholders' ), + 'quoted_table' => $db->quote_identifier( 'wptests_options' ), + 'quoted_weird' => $db->quote_identifier( 'weird"name' ), + 'prepared_identifier' => $db->prepare( + 'SELECT * FROM %i WHERE %i = %s', + 'wptests_options', + 'option_name', + "Bob's" + ), + 'prepared_string' => $db->prepare( 'SELECT %s', "Bob's" ), + ) + ); + PHP + ); + + $this->assertTrue( $result['has_identifier_cap'] ); + $this->assertSame( '"wptests_options"', $result['quoted_table'] ); + $this->assertSame( '"weird""name"', $result['quoted_weird'] ); + $this->assertSame( + 'SELECT * FROM "wptests_options" WHERE "option_name" = \'Bob\'\'s\'', + $result['prepared_identifier'] + ); + $this->assertSame( "SELECT 'Bob''s'", $result['prepared_string'] ); + } + /** * Tests query state, metadata, and SAVEQUERIES mapping. */ diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index bb1e52158..976b59f1a 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -316,7 +316,216 @@ public function prepare( $query, ...$args ) { return parent::prepare( $query, ...$args ); } - 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; + } + + return strtr( $prepared, $identifier_prepare['identifiers'] ); + } + + /** + * Rewrites common unnumbered identifier placeholders for PostgreSQL quoting. + * + * Core wpdb::prepare() hardcodes %i as a MySQL-backticked placeholder. This + * adapter supports the common unnumbered %i form by letting core prepare all + * non-identifier values, then replacing unquoted marker values with + * PostgreSQL-quoted identifiers. Numbered or formatted identifier placeholders + * fall back to core behavior until they can be mapped safely. + * + * @param string $query Query statement with placeholders. + * @param array $args Variables to substitute. + * @return array|null Rewritten prepare data, or null to use parent behavior. + */ + private function prepare_identifier_placeholders( $query, array $args ) { + if ( ! is_string( $query ) || false === strpos( $query, '%i' ) ) { + return null; + } + + $scan = $this->rewrite_identifier_placeholder_query( $query ); + if ( null === $scan ) { + return null; + } + + $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); + $prepare_args = $passed_as_array ? $args[0] : $args; + $identifiers = array(); + static $marker_id = 0; + + foreach ( $scan['identifier_arg_indexes'] as $index => $arg_index ) { + if ( ! array_key_exists( $arg_index, $prepare_args ) ) { + continue; + } + + ++$marker_id; + $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; + $identifiers[ $marker ] = $this->quote_identifier( $prepare_args[ $arg_index ] ); + $prepare_args[ $arg_index ] = $marker; + } + + return array( + 'query' => $scan['query'], + 'args' => $prepare_args, + 'identifiers' => $identifiers, + 'passed_as_array' => $passed_as_array, + ); + } + + /** + * Scans a prepare query and rewrites supported identifier placeholders. + * + * @param string $query Query statement with placeholders. + * @return array|null Rewritten query data, or null when unsupported. + */ + private function rewrite_identifier_placeholder_query( string $query ) { + $length = strlen( $query ); + $position = 0; + $copy_from = 0; + $placeholder_index = 0; + $rewritten = ''; + $has_identifier = false; + $has_numbered = false; + $has_escaped_candidate = false; + $identifier_arg_indexes = array(); + + while ( $position < $length ) { + if ( '%' !== $query[ $position ] ) { + ++$position; + continue; + } + + $run_start = $position; + while ( $position < $length && '%' === $query[ $position ] ) { + ++$position; + } + + $run_length = $position - $run_start; + if ( 0 === $run_length % 2 ) { + continue; + } + + $placeholder_start = $position - 1; + $placeholder = $this->read_prepare_placeholder( $query, $position ); + if ( null === $placeholder ) { + continue; + } + + if ( 1 < $run_length ) { + $has_escaped_candidate = true; + } + if ( $placeholder['numbered'] ) { + $has_numbered = true; + } + + if ( 'i' === $placeholder['type'] ) { + if ( '' !== $placeholder['format'] || 1 < $run_length ) { + return null; + } + + $has_identifier = true; + $identifier_arg_indexes[] = $placeholder_index; + $rewritten .= substr( $query, $copy_from, $placeholder_start - $copy_from ) . '%0s'; + $copy_from = $placeholder['end']; + } + + $position = $placeholder['end']; + ++$placeholder_index; + } + + if ( ! $has_identifier || $has_numbered || $has_escaped_candidate ) { + return null; + } + + return array( + 'query' => $rewritten . substr( $query, $copy_from ), + 'identifier_arg_indexes' => $identifier_arg_indexes, + ); + } + + /** + * Reads a wpdb::prepare() placeholder after the opening percent sign. + * + * @param string $query Query statement with placeholders. + * @param int $offset Offset immediately after the opening percent sign. + * @return array|null Placeholder metadata, or null when no placeholder matches. + */ + private function read_prepare_placeholder( string $query, int $offset ) { + $length = strlen( $query ); + $format_start = $offset; + $position = $offset; + $numbered = false; + + if ( $position < $length && '1' <= $query[ $position ] && '9' >= $query[ $position ] ) { + $digits_start = $position; + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '$' === $query[ $position ] ) { + $numbered = true; + ++$position; + } else { + $position = $digits_start; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length ) { + if ( ' ' === $query[ $position ] ) { + ++$position; + } elseif ( "'" === $query[ $position ] && $position + 1 < $length ) { + $position += 2; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '.' === $query[ $position ] ) { + ++$position; + if ( $position >= $length || ! ctype_digit( $query[ $position ] ) ) { + return null; + } + + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + } + + if ( $position >= $length || false === strpos( 'sdfFi', $query[ $position ] ) ) { + return null; + } + + return array( + 'format' => substr( $query, $format_start, $position - $format_start ), + 'type' => $query[ $position ], + 'end' => $position + 1, + 'numbered' => $numbered, + ); + } + + /** + * Checks whether a character is allowed in a wpdb prepare format segment. + * + * @param string $char Character to inspect. + * @return bool Whether the character is a format flag, sign, or width digit. + */ + private function is_prepare_format_flag( string $char ): bool { + return ctype_digit( $char ) || '-' === $char || '+' === $char; } /** From a7a95fb34ef9ed71bc4c5c34f64992cd6e6cb2d2 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 19:59:24 +0000 Subject: [PATCH 026/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 19:59:24 UTC. --- .../postgresql/class-wp-postgresql-db.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 976b59f1a..b5dee816c 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -357,9 +357,9 @@ private function prepare_identifier_placeholders( $query, array $args ) { return null; } - $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); - $prepare_args = $passed_as_array ? $args[0] : $args; - $identifiers = array(); + $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); + $prepare_args = $passed_as_array ? $args[0] : $args; + $identifiers = array(); static $marker_id = 0; foreach ( $scan['identifier_arg_indexes'] as $index => $arg_index ) { @@ -368,9 +368,9 @@ private function prepare_identifier_placeholders( $query, array $args ) { } ++$marker_id; - $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; - $identifiers[ $marker ] = $this->quote_identifier( $prepare_args[ $arg_index ] ); - $prepare_args[ $arg_index ] = $marker; + $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; + $identifiers[ $marker ] = $this->quote_identifier( $prepare_args[ $arg_index ] ); + $prepare_args[ $arg_index ] = $marker; } return array( @@ -394,8 +394,8 @@ private function rewrite_identifier_placeholder_query( string $query ) { $placeholder_index = 0; $rewritten = ''; $has_identifier = false; - $has_numbered = false; - $has_escaped_candidate = false; + $has_numbered = false; + $has_escaped_candidate = false; $identifier_arg_indexes = array(); while ( $position < $length ) { From 883c6f9e62b90ef0a9b5d1b5d8e3c571812a4a80 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:01:29 +0000 Subject: [PATCH 027/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:01:29 UTC. --- .../postgresql/class-wp-postgresql-driver.php | 126 +++++++++++++++-- .../tests/WP_PostgreSQL_DB_Tests.php | 128 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 50 +++++++ .../mysql-proxy/src/class-mysql-protocol.php | 2 +- 4 files changed, 291 insertions(+), 15 deletions(-) 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 index d803296b2..131b6f1b3 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -715,8 +715,8 @@ private function translate_simple_mysql_update_query( string $query ): ?string { * Translate simple single-table MySQL SELECT statements to PostgreSQL. * * This intentionally covers only the WordPress read shapes that need - * identifier quoting for PostgreSQL. Joins, grouping, limits, subqueries, - * functions, aliases, and MySQL-only SELECT modifiers fall through unchanged. + * identifier quoting for PostgreSQL. Joins, grouping, subqueries, most + * functions, and MySQL-only SELECT modifiers fall through unchanged. * * @param string $query MySQL query. * @return string|null PostgreSQL query, or null when the query is unsupported. @@ -740,9 +740,7 @@ private function translate_simple_mysql_select_query( string $query ): ?string { 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::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, @@ -753,7 +751,17 @@ private function translate_simple_mysql_select_query( string $query ): ?string { return null; } - $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + $select_end = $statement_end; + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $select_end ); if ( null === $from_position || 1 === $from_position ) { return null; } @@ -773,10 +781,10 @@ private function translate_simple_mysql_select_query( string $query ): ?string { $where_end = null; $order_position = null; - if ( $position < $statement_end && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + 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, $statement_end ); - $where_end = $order_position ?? $statement_end; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position + 1, $select_end ); + $where_end = $order_position ?? $select_end; if ( $where_position + 1 >= $where_end @@ -788,22 +796,22 @@ private function translate_simple_mysql_select_query( string $query ): ?string { $position = $where_end; } - if ( $position < $statement_end && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + 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, $statement_end ) ) { + if ( ! $this->is_supported_simple_select_order_by_clause( $tokens, $order_position, $select_end ) ) { return null; } - $position = $statement_end; + $position = $select_end; } - if ( $position !== $statement_end ) { + if ( $position !== $select_end ) { return null; } $sql = sprintf( 'SELECT %s FROM %s', - $this->translate_mysql_token_sequence_to_postgresql( $tokens, 1, $from_position ), + $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $from_position ), $this->translate_mysql_identifier_token_to_postgresql( $table_token ) ); @@ -817,11 +825,15 @@ private function translate_simple_mysql_select_query( string $query ): ?string { if ( null !== $order_position ) { $sql .= ' ORDER BY ' . $this->translate_mysql_token_to_postgresql( $tokens[ $order_position + 2 ] ); - if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $statement_end ) { + if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $select_end ) { $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); } } + if ( null !== $limit_position ) { + $sql .= ' LIMIT ' . $tokens[ $limit_position + 1 ]->get_bytes(); + } + return $sql; } @@ -1012,6 +1024,10 @@ private function is_supported_simple_select_projection( array $tokens, int $star return true; } + if ( $this->is_supported_simple_select_count_projection( $tokens, $start, $end ) ) { + return true; + } + for ( $i = $start; $i < $end; $i++ ) { if ( null === $this->get_mysql_identifier_token_value( $tokens[ $i ] ?? null ) ) { return false; @@ -1030,6 +1046,59 @@ private function is_supported_simple_select_projection( array $tokens, int $star return false; } + /** + * Validate the supported COUNT(identifier) projection shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the aggregate projection is supported. + */ + private function is_supported_simple_select_count_projection( array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + || WP_MySQL_Lexer::COUNT_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + || null === $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $start + 3 ]->id + ) { + return false; + } + + if ( $start + 4 === $end ) { + return true; + } + + return $start + 6 === $end + && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $start + 4 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $start + 5 ] ); + } + + /** + * Translate a supported simple SELECT projection to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return string PostgreSQL projection SQL. + */ + private function translate_simple_select_projection_to_postgresql( array $tokens, int $start, int $end ): string { + if ( $this->is_supported_simple_select_count_projection( $tokens, $start, $end ) ) { + $sql = sprintf( + 'COUNT(%s)', + $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ) + ); + + if ( $start + 6 === $end ) { + $sql .= ' ' . $tokens[ $start + 4 ]->get_bytes() . ' ' . $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 5 ] ); + } + + return $sql; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + } + /** * Validate the simple expression fragments used by translated DML/SELECT. * @@ -1113,6 +1182,35 @@ private function is_supported_simple_select_order_by_clause( array $tokens, int ); } + /** + * Validate a safe trailing SELECT LIMIT clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start LIMIT token position. + * @param int $end Final clause token position, exclusive. + * @return bool Whether the LIMIT clause is supported. + */ + private function is_supported_simple_select_limit_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 !== $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $start ]->id + || ! in_array( + $tokens[ $start + 1 ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return false; + } + + return ctype_digit( $tokens[ $start + 1 ]->get_value() ); + } + /** * Find the token position ending a single MySQL statement. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 4ae768171..8b51b94ef 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -147,6 +147,134 @@ public function get_connection(): WP_PostgreSQL_Connection { $this->assertSame( "SELECT 'Bob''s'", $result['prepared_string'] ); } + /** + * Tests db_connect() with a reusable PostgreSQL PDO and connection lifecycle methods. + */ + public function test_db_connect_reuses_global_postgresql_pdo_and_exposes_connection_lifecycle(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbuser = ''; + public $dbpassword = ''; + public $dbname = ''; + public $dbhost = ''; + public $ready = false; + public $is_mysql = true; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function parse_db_host( $host ) { + return false; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Connect_Fake_PDO extends PDO { + public $attributes = array(); + + public function __construct() {} + + public function setAttribute( $attribute, $value ): bool { + $this->attributes[ $attribute ] = $value; + return true; + } + + public function getAttribute( $attribute ): mixed { + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + return 'pgsql'; + } + + if ( PDO::ATTR_SERVER_VERSION === $attribute ) { + return 'PostgreSQL 16 test'; + } + + return $this->attributes[ $attribute ] ?? null; + } +} + +$pdo = new WP_PostgreSQL_DB_Connect_Fake_PDO(); +$GLOBALS['@pdo'] = $pdo; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$db->dbuser = 'wptests_user'; +$db->dbpassword = 'wptests_password'; +$db->dbname = 'wptests'; +$db->dbhost = 'localhost'; + +$connect_result = $db->db_connect( false ); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver = $driver_property->getValue( $db ); + +$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' => $select_current_result && $ready_after_current, + 'is_mysql' => $db->is_mysql, + 'last_error' => $db->last_error, + 'charset' => $db->charset, + 'bail_calls' => $db->bail_calls, + 'reused_global_pdo' => $pdo === $GLOBALS['@pdo'], + 'server_info' => $server_info, + 'select_other_result' => $select_other_result, + 'ready_after_other' => $ready_after_other, + 'select_current_result' => $select_current_result, + 'ready_after_current' => $ready_after_current, + 'close_result' => $close_result, + 'ready_after_close' => $ready_after_close, + 'driver_after_close' => null === $driver_after_close, + 'second_close_result' => $second_close_result, + ) +); +PHP + ); + + $this->assertSame( + array( + 'connect_result' => true, + 'ready_after_connect' => true, + 'is_mysql' => false, + 'last_error' => '', + 'charset' => 'utf8mb4', + 'bail_calls' => array(), + 'reused_global_pdo' => true, + 'server_info' => 'PostgreSQL 16 test', + 'select_other_result' => false, + 'ready_after_other' => false, + 'select_current_result' => true, + 'ready_after_current' => true, + 'close_result' => true, + 'ready_after_close' => false, + 'driver_after_close' => true, + 'second_close_result' => false, + ), + $result + ); + } + /** * Tests query state, metadata, and SAVEQUERIES mapping. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 6d9a6b761..bbcf0f8cc 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -128,6 +128,56 @@ public function test_simple_select_with_bare_uppercase_id_is_translated_to_postg ); } + /** + * 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. */ 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]; From d37acb6dba616304e15f512e26502ce78bd9f30a Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:02:33 +0000 Subject: [PATCH 028/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:02:33 UTC. --- ...-wp-postgresql-create-table-translator.php | 2 + .../postgresql/class-wp-postgresql-driver.php | 58 ++++++++++++++----- .../tests/WP_PostgreSQL_DB_Tests.php | 12 ++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 25 ++++++++ 4 files changed, 79 insertions(+), 18 deletions(-) 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 index 8e2e27255..c2d340ceb 100644 --- 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 @@ -218,6 +218,8 @@ private function translate_data_type( ?WP_Parser_Node $data_type, bool $is_auto_ $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; } elseif ( in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'datetime', 'timestamp', 'date', 'time', 'year' ), true ) ) { $postgresql_type = 'text'; + } elseif ( in_array( $type, array( 'tinyblob', 'blob', 'mediumblob', 'longblob' ), true ) ) { + $postgresql_type = 'bytea'; } else { throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); } 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 index 131b6f1b3..e0699786d 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -831,7 +831,7 @@ private function translate_simple_mysql_select_query( string $query ): ?string { } if ( null !== $limit_position ) { - $sql .= ' LIMIT ' . $tokens[ $limit_position + 1 ]->get_bytes(); + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } return $sql; @@ -1192,23 +1192,55 @@ private function is_supported_simple_select_order_by_clause( array $tokens, int */ private function is_supported_simple_select_limit_clause( array $tokens, int $start, int $end ): bool { if ( - $start + 2 !== $end - || ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $start ]->id - || ! in_array( - $tokens[ $start + 1 ]->id, - array( - WP_MySQL_Lexer::INT_NUMBER, - WP_MySQL_Lexer::LONG_NUMBER, - WP_MySQL_Lexer::ULONGLONG_NUMBER, - ), - true - ) ) { return false; } - return ctype_digit( $tokens[ $start + 1 ]->get_value() ); + if ( $start + 2 === $end ) { + return $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ); + } + + return $start + 4 === $end + && isset( $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $start + 2 ]->id + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 3 ] ); + } + + /** + * Validate a LIMIT number token. + * + * @param WP_MySQL_Token $token MySQL lexer token. + * @return bool Whether the token is a supported non-negative integer. + */ + private function is_supported_simple_select_limit_number( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) && ctype_digit( $token->get_value() ); + } + + /** + * Translate a supported trailing SELECT LIMIT clause to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start LIMIT token position. + * @param int $end Final clause token position, exclusive. + * @return string PostgreSQL LIMIT clause. + */ + private function translate_simple_select_limit_clause_to_postgresql( array $tokens, int $start, int $end ): string { + if ( $start + 4 === $end ) { + return ' LIMIT ' . $tokens[ $start + 3 ]->get_bytes() . ' OFFSET ' . $tokens[ $start + 1 ]->get_bytes(); + } + + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes(); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 8b51b94ef..da37dde34 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -213,11 +213,13 @@ public function getAttribute( $attribute ): mixed { $db->dbname = 'wptests'; $db->dbhost = 'localhost'; -$connect_result = $db->db_connect( false ); +$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 = $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; @@ -232,12 +234,12 @@ public function getAttribute( $attribute ): mixed { wp_postgresql_db_test_respond( array( 'connect_result' => $connect_result, - 'ready_after_connect' => $select_current_result && $ready_after_current, + 'ready_after_connect' => $ready_after_connect, 'is_mysql' => $db->is_mysql, 'last_error' => $db->last_error, 'charset' => $db->charset, 'bail_calls' => $db->bail_calls, - 'reused_global_pdo' => $pdo === $GLOBALS['@pdo'], + 'driver_uses_global_pdo' => $driver_uses_global_pdo, 'server_info' => $server_info, 'select_other_result' => $select_other_result, 'ready_after_other' => $ready_after_other, @@ -260,7 +262,7 @@ public function getAttribute( $attribute ): mixed { 'last_error' => '', 'charset' => 'utf8mb4', 'bail_calls' => array(), - 'reused_global_pdo' => true, + 'driver_uses_global_pdo' => true, 'server_info' => 'PostgreSQL 16 test', 'select_other_result' => false, 'ready_after_other' => false, diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index bbcf0f8cc..051396ab9 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -203,6 +203,31 @@ public function test_simple_select_with_mixed_case_comment_identifiers_is_transl ); } + /** + * 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 successive queries reset result metadata and backend query logs. */ From 4aa51cab4a655f33c6d814c89f9c2b03b1e10300 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:03:37 +0000 Subject: [PATCH 029/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:03:37 UTC. --- .../tests/WP_PostgreSQL_DB_Tests.php | 30 +++++++++---------- .../tests/WP_SQLite_Driver_Tests.php | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index da37dde34..53bd2eecb 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -256,22 +256,22 @@ public function getAttribute( $attribute ): mixed { $this->assertSame( array( - 'connect_result' => true, - 'ready_after_connect' => true, - 'is_mysql' => false, - 'last_error' => '', - 'charset' => 'utf8mb4', - 'bail_calls' => array(), + 'connect_result' => true, + 'ready_after_connect' => true, + 'is_mysql' => false, + 'last_error' => '', + 'charset' => 'utf8mb4', + 'bail_calls' => array(), 'driver_uses_global_pdo' => true, - 'server_info' => 'PostgreSQL 16 test', - 'select_other_result' => false, - 'ready_after_other' => false, - 'select_current_result' => true, - 'ready_after_current' => true, - 'close_result' => true, - 'ready_after_close' => false, - 'driver_after_close' => true, - 'second_close_result' => false, + 'server_info' => 'PostgreSQL 16 test', + 'select_other_result' => false, + 'ready_after_other' => false, + 'select_current_result' => true, + 'ready_after_current' => true, + 'close_result' => true, + 'ready_after_close' => false, + 'driver_after_close' => true, + 'second_close_result' => false, ), $result ); diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 18a0424c9..7322cf5a9 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -4241,7 +4241,7 @@ public function testTranslateLikeBinary() { 'CREATE TABLE _tmp_table ( ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, name varchar(20) - )' + )' ); // Insert data into the table From c0958497164f07204e49cfad60ff24273bb0a604 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:04:41 +0000 Subject: [PATCH 030/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:04:41 UTC. --- .../postgresql/class-wp-postgresql-driver.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) 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 index e0699786d..fdb4b923e 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -160,6 +160,16 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_describe_query( $describe_table_name, $fetch_mode, ...$fetch_mode_args ); } + $show_tables_query = $this->get_show_tables_query( $query ); + if ( null !== $show_tables_query ) { + return $this->execute_show_tables_query( + $show_tables_query['full'], + $show_tables_query['like'], + $fetch_mode, + ...$fetch_mode_args + ); + } + $translated_query = $this->translate_wordpress_options_regexp_delete_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -253,6 +263,56 @@ private function get_describe_table_name( string $query ): ?string { return $table_name; } + /** + * Parse a supported MySQL SHOW TABLES statement. + * + * @param string $query MySQL query. + * @return array{full: bool, like: string|null}|null SHOW TABLES options, or null when unsupported. + */ + private function get_show_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $is_full = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $like = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + ) + ) { + return null; + } + + $like = $tokens[ $position + 1 ]->get_value(); + $position += 2; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return array( + 'full' => $is_full, + 'like' => $like, + ); + } + /** * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. * @@ -276,6 +336,47 @@ private function execute_describe_query( string $table_name, $fetch_mode, ...$fe return $this->last_result; } + /** + * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. + * + * @param bool $is_full Whether this is SHOW FULL TABLES. + * @param string|null $like Optional MySQL LIKE pattern. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW TABLES result rows. + */ + private function execute_show_tables_query( bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + $table_column = $this->connection->quote_identifier( 'Tables_in_' . $this->db_name ); + $sql = sprintf( + 'SELECT table_name AS %s%s +FROM information_schema.tables +WHERE table_schema = ? + AND table_type IN (\'BASE TABLE\', \'VIEW\')', + $table_column, + $is_full ? ', CASE WHEN table_type = \'VIEW\' THEN \'VIEW\' ELSE \'BASE TABLE\' END AS "Table_type"' : '' + ); + $params = array( 'public' ); + + if ( null !== $like ) { + $sql .= " AND table_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + $sql .= ' +ORDER BY table_name'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + /** * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. * From ec6cc35de6f15c72d05ed2a38d852aef555480b6 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:05:44 +0000 Subject: [PATCH 031/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:05:44 UTC. --- wp-setup.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wp-setup.sh b/wp-setup.sh index 1c28c142e..de2d866be 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -104,6 +104,10 @@ FROM wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e3836 USER root +RUN if command -v git > /dev/null; then \ + 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 \ @@ -122,6 +126,10 @@ FROM wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d7 USER root +RUN if command -v git > /dev/null; then \ + 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 \ From 00941c0357997e6f927c6bf7d6c6822cdc0090a0 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:06:48 +0000 Subject: [PATCH 032/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:06:48 UTC. --- .../tests/WP_PostgreSQL_Driver_Tests.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 051396ab9..9fdc88c40 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -567,6 +567,39 @@ public function test_desc_existing_table_returns_mysql_shaped_column_metadata(): $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); } + /** + * Tests SHOW TABLES returns MySQL-shaped catalog rows. + */ + public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW TABLES LIKE 'wptests_%'" ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); + $this->assertSame( 'wptests_posts', $tables[1]->Tables_in_wptests ); + $this->assertSame( 'wptests_view', $tables[2]->Tables_in_wptests ); + $this->assertSame( "SHOW TABLES LIKE 'wptests_%'", $driver->get_last_mysql_query() ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.tables', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_%' ), $queries[0]['params'] ); + + $full_tables = $driver->query( "SHOW FULL TABLES LIKE 'wptests_%'" ); + + $this->assertCount( 3, $full_tables ); + $this->assertSame( 'wptests_options', $full_tables[0]->Tables_in_wptests ); + $this->assertSame( 'BASE TABLE', $full_tables[0]->Table_type ); + $this->assertSame( 'wptests_view', $full_tables[2]->Tables_in_wptests ); + $this->assertSame( 'VIEW', $full_tables[2]->Table_type ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Table_type', $driver->get_last_column_meta()[1]['name'] ); + } + /** * Tests MySQL-only runtime SET statements are ignored before reaching PDO. */ @@ -637,6 +670,13 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive $pdo = $driver->get_connection()->get_pdo(); $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( + 'CREATE TABLE information_schema.tables ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + table_type TEXT NOT NULL + )' + ); $pdo->exec( 'CREATE TABLE information_schema.columns ( table_schema TEXT NOT NULL, @@ -669,6 +709,15 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive )' ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_options', 'BASE TABLE'), + ('public', 'wptests_posts', 'BASE TABLE'), + ('public', 'wptests_view', 'VIEW'), + ('other', 'other_table', 'BASE TABLE')" + ); $pdo->exec( "INSERT INTO information_schema.columns (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, is_nullable, column_default, is_identity) From adf0fabfbe7f624eb2f7d3870c6cf8255bd2d1ed Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:09:55 +0000 Subject: [PATCH 033/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:09:55 UTC. --- .../postgresql/class-wp-postgresql-driver.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 index fdb4b923e..664384476 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -687,8 +687,10 @@ private function translate_wordpress_options_upsert_query( string $query ): ?str * Translate simple single-row MySQL INSERT statements to PostgreSQL. * * WordPress CRUD helpers emit a narrow INSERT INTO table (columns) VALUES - * (...) shape. MySQL-specific modifiers, INSERT ... SELECT/SET, missing - * column lists, multi-row values, and trailing clauses fall through unchanged. + * (...) shape. INSERT IGNORE uses PostgreSQL's conflict no-op syntax for + * the same simple VALUES shape. Other MySQL-specific modifiers, + * INSERT ... SELECT/SET, missing column lists, multi-row values, and + * trailing clauses fall through unchanged. * * @param string $query MySQL query. * @return string|null PostgreSQL query, or null when the query is unsupported. @@ -700,6 +702,12 @@ private function translate_simple_mysql_insert_query( string $query ): ?string { } $position = 1; + $ignore = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + $ignore = true; + ++$position; + } + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { return null; } @@ -733,12 +741,14 @@ private function translate_simple_mysql_insert_query( string $query ): ?string { return null; } - return sprintf( + $sql = sprintf( 'INSERT INTO %s (%s) %s', $this->connection->quote_identifier( $table_name ), implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), $this->translate_mysql_token_sequence_to_postgresql( $tokens, $values_start, $values_end ) ); + + return $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql; } /** From 7881b9e93a5f427d2e4f54ad4597c7b1a9b7c687 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:12:00 +0000 Subject: [PATCH 034/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:12:00 UTC. --- .github/workflows/wp-tests-phpunit-run.js | 29 +++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index d891ec692..fccfc4fa4 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -126,6 +126,11 @@ try { 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', @@ -133,19 +138,29 @@ try { ); console.log( '\nAll tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { + phpunitCommandError = error; console.log( '\nSome tests errored/failed. Analyzing results...' ); } - const junitOutputFile = path.join( repositoryRoot, 'wordpress', 'phpunit-results.xml' ); if ( ! fs.existsSync( junitOutputFile ) ) { 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 testcases = readJunitTestcases( junitOutputFile ); const summary = summarizeTestcases( testcases ); writeResultSummary( summary ); + if ( 0 === summary.total ) { + const failureContext = phpunitCommandError ? ' after the PHPUnit command failed' : ''; + console.error( `Error: JUnit output did not contain any test cases${ failureContext }.` ); + process.exit( 1 ); + } const actualErrors = testcases.filter( testcase => testcase.hasError ).map( testcase => testcase.name ); const actualFailures = testcases.filter( testcase => testcase.hasFailure ).map( testcase => testcase.name ); @@ -490,6 +505,12 @@ function readGeneratedFile( file ) { return fs.readFileSync( file, 'utf8' ); } +function removeStaleTestOutput( file ) { + if ( fs.existsSync( file ) ) { + fs.unlinkSync( file ); + } +} + function readJunitTestcases( junitOutputFile ) { const parserPath = require.resolve( 'fast-xml-parser', { paths: [ @@ -621,7 +642,7 @@ function emptySummary() { } function writeResultSummary( summary ) { - const outputPath = path.join( repositoryRoot, `wp-phpunit-results-${ backend }.json` ); + const outputPath = getResultSummaryFile(); fs.writeFileSync( outputPath, `${ JSON.stringify( summary, null, 2 ) }\n` ); if ( process.env.GITHUB_OUTPUT ) { @@ -635,3 +656,7 @@ function writeResultSummary( summary ) { fs.appendFileSync( process.env.GITHUB_OUTPUT, `${ output }\n` ); } } + +function getResultSummaryFile() { + return path.join( repositoryRoot, `wp-phpunit-results-${ backend }.json` ); +} From f12a9e4957916da5caabd9e030efb623c95b23ad Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:13:05 +0000 Subject: [PATCH 035/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:13:05 UTC. --- .../postgresql/class-wp-postgresql-driver.php | 43 ++++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 6 +-- 2 files changed, 40 insertions(+), 9 deletions(-) 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 index 664384476..e05c232f4 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -791,7 +791,6 @@ private function translate_simple_mysql_update_query( string $query ): ?string { } $unsupported_tokens = array( - WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::JOIN_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL, WP_MySQL_Lexer::ORDER_SYMBOL, @@ -808,7 +807,10 @@ private function translate_simple_mysql_update_query( string $query ): ?string { ); if ( null !== $where_position ) { - if ( $where_position + 1 >= $statement_end ) { + if ( + $where_position + 1 >= $statement_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $statement_end ) + ) { return null; } @@ -1116,10 +1118,39 @@ private function parse_upsert_update_assignments( array $tokens, int &$position, * @return bool Whether the SET clause is supported. */ private function is_supported_simple_update_set_clause( array $tokens, int $start, int $end ): bool { - return $start + 2 < $end - && null !== $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ) - && isset( $tokens[ $start + 1 ] ) - && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $start + 1 ]->id; + for ( $position = $start; $position < $end; ) { + if ( + null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id + ) { + return false; + } + + $value_start = $position + 2; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( + $value_start >= $assignment_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $value_start, $assignment_end ) + ) { + return false; + } + + $position = $assignment_end; + if ( $position === $end ) { + return true; + } + + ++$position; + } + + return false; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 9fdc88c40..6b3caea1e 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -449,9 +449,9 @@ public function test_simple_delete_with_bare_uppercase_id_where_is_translated_to } /** - * Tests UPDATE shapes with top-level commas still reach the backend unchanged. + * Tests multi-assignment WordPress UPDATE statements are translated to PostgreSQL. */ - public function test_unsupported_update_with_comma_still_reaches_backend(): void { + public function test_multi_assignment_wordpress_update_with_backticks_is_translated_to_postgresql(): void { $driver = $this->create_driver(); $driver->query( @@ -469,7 +469,7 @@ public function test_unsupported_update_with_comma_still_reaches_backend(): void $this->assertSame( array( array( - 'sql' => $update, + 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\', "autoload" = \'yes\' WHERE "option_name" = \'key1\'', 'params' => array(), ), ), From d24e79cf0464b21b4bb976749e3db113dd012384 Mon Sep 17 00:00:00 2001 From: adamziel Date: Tue, 9 Jun 2026 20:17:40 +0000 Subject: [PATCH 036/142] Integrate lane work snapshot Snapshot captured at 2026-06-09 20:17:40 UTC. --- packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 53bd2eecb..7a1fb1ce1 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -134,7 +134,7 @@ public function get_connection(): WP_PostgreSQL_Connection { 'prepared_string' => $db->prepare( 'SELECT %s', "Bob's" ), ) ); - PHP +PHP ); $this->assertTrue( $result['has_identifier_cap'] ); From e271162067474ee830a69edfa62aa48d36b3ead5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 00:11:58 +0000 Subject: [PATCH 037/142] Checkpoint PostgreSQL backend support work --- composer.json | 2 +- ...-wp-postgresql-create-table-translator.php | 510 ++- .../postgresql/class-wp-postgresql-driver.php | 3318 ++++++++++++++++- ...stgreSQL_Create_Table_Translator_Tests.php | 80 + .../tests/WP_PostgreSQL_DB_Tests.php | 263 +- .../tests/WP_PostgreSQL_Driver_Tests.php | 1054 +++++- .../WP_PostgreSQL_Install_Functions_Tests.php | 92 +- .../postgresql/class-wp-postgresql-db.php | 1017 ++++- .../postgresql/install-functions.php | 39 +- wp-setup.sh | 24 +- 10 files changed, 6153 insertions(+), 246 deletions(-) diff --git a/composer.json b/composer.json index a7687f494..3b344bca8 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "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 ) { 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 }` ] ] ); } if ( \"postgresql\" === backend ) { checks.push( [ \"wordpress/tools/local-env/Dockerfile.postgresql-php\", [ \"docker-php-ext-install pdo_pgsql\" ] ], [ \"wordpress/tools/local-env/Dockerfile.postgresql-cli\", [ \"docker-php-ext-install pdo_pgsql\" ] ], [ \"wordpress/tools/local-env/scripts/install.js\", [ \"--dbhost=postgres\", \"--skip-check\", \"DB_ENGINE postgresql\", \"DATABASE_ENGINE postgresql\", `const { existsSync, renameSync, readFileSync, writeFileSync } = require( ${ quote }fs${ quote } );`, `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", + "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 ) { 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 }` ] ] ); } 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 { existsSync, renameSync, readFileSync, writeFileSync } = require( ${ quote }fs${ quote } );`, `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", "wp-test-ensure-env": [ "@wp-test-ensure-backend @no_additional_args", "@putenv COMPOSE_IGNORE_ORPHANS=true", 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 index c2d340ceb..6af101cca 100644 --- 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 @@ -10,6 +10,20 @@ class WP_PostgreSQL_Create_Table_Translator { const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; + const CHARSET_DEFAULT_COLLATION_MAP = array( + 'ascii' => 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + ); + /** * Reusable MySQL grammar. * @@ -102,6 +116,31 @@ public function translate( WP_Parser_Node $create_statement ): array { 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. * @@ -216,10 +255,15 @@ private function translate_data_type( ?WP_Parser_Node $data_type, bool $is_auto_ } elseif ( in_array( $type, array( 'varchar', 'char' ), true ) ) { $length = $this->get_field_length( $data_type ); $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; - } elseif ( in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'datetime', 'timestamp', 'date', 'time', 'year' ), true ) ) { + } elseif ( in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'datetime', 'timestamp', 'date', 'time', 'year', 'geometrycollection', 'geomcollection' ), true ) ) { $postgresql_type = 'text'; - } elseif ( in_array( $type, array( 'tinyblob', 'blob', 'mediumblob', 'longblob' ), true ) ) { + } elseif ( in_array( $type, array( 'binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob' ), true ) ) { $postgresql_type = 'bytea'; + } elseif ( in_array( $type, array( 'float', 'double' ), true ) ) { + $precision_fragment = $this->get_numeric_precision_fragment( $data_type ); + $postgresql_type = '' === $precision_fragment ? 'double precision' : 'numeric' . $precision_fragment; + } elseif ( in_array( $type, array( 'decimal', 'numeric' ), true ) ) { + $postgresql_type = 'numeric' . $this->get_numeric_precision_fragment( $data_type ); } else { throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); } @@ -358,6 +402,452 @@ private function get_identifier_value( ?WP_Parser_Node $node ): string { 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 ); + list ( $table_charset, $table_collation ) = $this->get_table_charset_and_collation( $create_table ); + $columns = array(); + $column_types = array(); + $indexes = array(); + $ordinal = 1; + $index_ordinal = 1; + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + return array( + 'table_name' => $table_name, + 'columns' => array(), + ); + } + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + $data_type = $field_definition ? $field_definition->get_first_child_node( 'dataType' ) : null; + $column_type = $this->get_mysql_column_type( $data_type, $field_definition ); + + list ( $charset, $collation ) = $this->get_column_charset_and_collation( + $field_definition, + $this->get_base_mysql_column_type( $column_type ), + $table_charset, + $table_collation + ); + + $column_metadata = array( + 'name' => $name, + 'type' => $column_type, + 'charset' => $charset, + 'collation' => $collation, + 'ordinal' => $ordinal, + ); + + if ( $include_indexes ) { + $column_metadata['nullable'] = $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::NOT_SYMBOL ) ? 'NO' : 'YES'; + $column_metadata['default'] = $field_definition ? $this->get_column_default_metadata( $field_definition ) : null; + $column_metadata['extra'] = $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ? 'auto_increment' : ''; + } + + $columns[] = $column_metadata; + $column_types[ strtolower( $name ) ] = $column_type; + ++$ordinal; + continue; + } + + if ( $include_indexes ) { + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( $table_constraint ) { + $indexes[] = $this->extract_index_metadata( $table_constraint, $index_ordinal, $column_types ); + ++$index_ordinal; + } + } + } + + $metadata = array( + 'table_name' => $table_name, + 'columns' => $columns, + ); + + if ( $include_indexes ) { + $metadata['indexes'] = $indexes; + } + + return $metadata; + } + + /** + * Extract metadata for a table index. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param int $index_ordinal Index ordinal. + * @param array $column_types Column types keyed by lowercase name. + * @return array Index metadata. + */ + private function extract_index_metadata( WP_Parser_Node $table_constraint, int $index_ordinal, array $column_types ): array { + $is_primary = $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); + $key_parts = $this->get_key_part_metadata( $table_constraint, $column_types ); + $key_name = $is_primary ? 'PRIMARY' : $this->get_index_name( $table_constraint, $key_parts ); + + return array( + 'name' => $key_name, + 'ordinal' => $index_ordinal, + 'non_unique' => $is_primary || $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? '0' : '1', + 'index_type' => $this->get_mysql_index_type_metadata( $table_constraint ), + 'columns' => $key_parts, + ); + } + + /** + * Get an index name from a table constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $key_parts Key part metadata. + * @return string Index name. + */ + private function get_index_name( WP_Parser_Node $table_constraint, array $key_parts ): string { + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name_node = $index_name_node ? $index_name_node->get_first_child_node( 'indexName' ) : $table_constraint->get_first_child_node( 'indexName' ); + + if ( $index_name_node ) { + return $this->get_identifier_value( $index_name_node ); + } + + return (string) $key_parts[0]['column_name']; + } + + /** + * Get MySQL SHOW INDEX Index_type metadata. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string Index type. + */ + private function get_mysql_index_type_metadata( WP_Parser_Node $table_constraint ): string { + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + return 'FULLTEXT'; + } + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) ) { + return 'SPATIAL'; + } + + return 'BTREE'; + } + + /** + * Get key part metadata from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $column_types Column types keyed by lowercase name. + * @return array[] Key part metadata. + */ + private function get_key_part_metadata( WP_Parser_Node $table_constraint, array $column_types ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $sub_part = $this->get_field_length( $key_part ); + if ( null === $sub_part && ! $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + } + + $key_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $key_parts ) + 1, + 'sub_part' => $sub_part, + ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get the implicit MySQL prefix length for oversized utf8mb4 string indexes. + * + * @param string $column_name Column name. + * @param array $column_types Column types keyed by lowercase name. + * @return int|null Sub part length. + */ + private function get_implicit_index_sub_part( string $column_name, array $column_types ): ?int { + $column_type = $column_types[ strtolower( $column_name ) ] ?? null; + if ( ! is_string( $column_type ) || ! preg_match( '/^(?:var)?char\((\d+)\)$/i', $column_type, $matches ) ) { + return null; + } + + $length = (int) $matches[1]; + return $length > 191 ? 191 : null; + } + + /** + * Get a MySQL default value for DESCRIBE metadata. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return string|null Default value. + */ + private function get_column_default_metadata( WP_Parser_Node $field_definition ): ?string { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( ! $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::NULL_SYMBOL ) ) { + return null; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + return $token->get_value(); + } + } + + return null; + } + + /** + * Extract table default charset and collation. + * + * @param WP_Parser_Node $create_table Create table node. + * @return array{string, string} Charset and collation. + */ + private function get_table_charset_and_collation( WP_Parser_Node $create_table ): array { + $charset = 'utf8mb4'; + $collation = null; + + foreach ( $create_table->get_child_nodes( 'createTableOptions' ) as $options ) { + foreach ( $options->get_child_nodes( 'createTableOption' ) as $option ) { + $default_charset = $option->get_first_child_node( 'defaultCharset' ); + if ( $default_charset ) { + $charset_name = $default_charset->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } + } + + $default_collation = $option->get_first_child_node( 'defaultCollation' ); + if ( $default_collation ) { + $collation_name = $default_collation->get_first_child_node( 'collationName' ); + if ( $collation_name ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_name ) ); + } + } + } + } + + if ( null === $collation ) { + $collation = $this->get_default_collation_for_charset( $charset ); + } else { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Extract MySQL column charset and collation metadata. + * + * @param WP_Parser_Node|null $field_definition Field definition node. + * @param string $data_type Column data type. + * @param string $table_charset Table default charset. + * @param string $table_collation Table default collation. + * @return array{string|null, string|null} Charset and collation. + */ + private function get_column_charset_and_collation( ?WP_Parser_Node $field_definition, string $data_type, string $table_charset, string $table_collation ): array { + if ( ! $field_definition || ! $this->is_mysql_character_data_type( $data_type ) ) { + return array( null, null ); + } + + $charset = null; + $collation = null; + $is_binary = false; + + $charset_node = $field_definition->get_first_descendant_node( 'charsetWithOptBinary' ); + if ( $charset_node ) { + $charset_name = $charset_node->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::ASCII_SYMBOL ) ) { + $charset = 'latin1'; + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::UNICODE_SYMBOL ) ) { + $charset = 'ucs2'; + } + + if ( $charset_node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { + $is_binary = true; + } + } + + $collation_node = $field_definition->get_first_descendant_node( 'collationName' ); + if ( $collation_node ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_node ) ); + } + + if ( null === $charset && null === $collation ) { + $charset = $table_charset; + $collation = $table_collation; + } elseif ( null === $collation ) { + $collation = $is_binary ? $charset . '_bin' : $this->get_default_collation_for_charset( $charset ); + } elseif ( null === $charset ) { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Get a MySQL column type for metadata. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param WP_Parser_Node|null $field_definition Field definition node. + * @return string MySQL column type. + */ + private function get_mysql_column_type( ?WP_Parser_Node $data_type, ?WP_Parser_Node $field_definition = null ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + $type = strtolower( $type_token->get_value() ); + if ( 'integer' === $type ) { + $type = 'int'; + } + + $numeric_precision = $this->get_numeric_precision_fragment( $data_type ); + if ( '' !== $numeric_precision && in_array( $type, array( 'decimal', 'double', 'float', 'numeric' ), true ) ) { + $type .= $numeric_precision; + } + + $length = $this->get_field_length( $data_type ); + if ( null !== $length && in_array( $type, array( 'bigint', 'binary', 'char', 'int', 'mediumint', 'smallint', 'tinyint', 'varbinary', 'varchar' ), true ) ) { + $type = sprintf( '%s(%d)', $type, $length ); + } + + if ( $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::UNSIGNED_SYMBOL ) ) { + $type .= ' unsigned'; + } + + return $type; + } + + /** + * Get the base MySQL column type without length. + * + * @param string $column_type Column type. + * @return string Base type. + */ + private function get_base_mysql_column_type( string $column_type ): string { + $length_position = strpos( $column_type, '(' ); + if ( false === $length_position ) { + return strtolower( $column_type ); + } + + return strtolower( substr( $column_type, 0, $length_position ) ); + } + + /** + * Check whether a MySQL type has character set metadata. + * + * @param string $data_type Base MySQL data type. + * @return bool Whether the type is textual. + */ + private function is_mysql_character_data_type( string $data_type ): bool { + return in_array( + $data_type, + array( 'char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set' ), + true + ); + } + + /** + * Normalize MySQL charset names for WordPress metadata expectations. + * + * @param string $charset Charset name. + * @return string Normalized charset. + */ + private function normalize_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Normalize MySQL collation names for WordPress metadata expectations. + * + * @param string $collation Collation name. + * @return string Normalized collation. + */ + private function normalize_collation( string $collation ): string { + $collation = strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $collation, 'utf8mb3_' ) ) { + return 'utf8_' . substr( $collation, strlen( 'utf8mb3_' ) ); + } + + return $collation; + } + + /** + * Get the charset prefix from a collation name. + * + * @param string $collation Collation name. + * @return string Charset name. + */ + private function get_charset_from_collation( string $collation ): string { + $underscore = strpos( $collation, '_' ); + if ( false === $underscore ) { + return $this->normalize_charset( $collation ); + } + + return $this->normalize_charset( substr( $collation, 0, $underscore ) ); + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset name. + * @return string Collation name. + */ + private function get_default_collation_for_charset( string $charset ): string { + $charset = $this->normalize_charset( $charset ); + if ( isset( self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ) ) { + return self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ]; + } + + return $charset . '_general_ci'; + } + + /** + * Serialize a parser node value. + * + * @param WP_Parser_Node $node Parser node. + * @return string Node value. + */ + private function get_node_value( WP_Parser_Node $node ): string { + $value = ''; + foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_Parser_Node ) { + $value .= $this->get_node_value( $child ); + } else { + $value .= $child->get_value(); + } + } + + return $value; + } + /** * Get a numeric field length. * @@ -374,6 +864,22 @@ private function get_field_length( WP_Parser_Node $node ): ?int { return $token ? (int) $token->get_value() : null; } + /** + * Get a numeric precision/scale SQL fragment. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Precision fragment, including parentheses, or empty string. + */ + private function get_numeric_precision_fragment( WP_Parser_Node $data_type ): string { + $precision = $data_type->get_first_descendant_node( 'precision' ); + if ( $precision ) { + return $this->get_node_value( $precision ); + } + + $field_length = $data_type->get_first_descendant_node( 'fieldLength' ); + return $field_length ? $this->get_node_value( $field_length ) : ''; + } + /** * Quote a PostgreSQL identifier. * 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 index e05c232f4..665799033 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -13,6 +13,12 @@ * emulation layer is extracted in later work. */ class WP_PostgreSQL_Driver { + const MYSQL_COLUMN_METADATA_TABLE = '__wp_postgresql_mysql_column_metadata'; + const MYSQL_INDEX_METADATA_TABLE = '__wp_postgresql_mysql_index_metadata'; + const MYSQL_CHARSET_METADATA_TABLE = '__wp_postgresql_mysql_charset_metadata'; + const DEFAULT_MYSQL_CHARSET = 'utf8mb4'; + const DEFAULT_MYSQL_COLLATION = 'utf8mb4_unicode_ci'; + /** * PostgreSQL server version string. * @@ -62,6 +68,41 @@ class WP_PostgreSQL_Driver { */ private $last_postgresql_queries = array(); + /** + * Approximate FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. + * + * @var int + */ + private $last_found_rows = 0; + + /** + * MySQL-compatible session SQL mode state. + * + * @var string + */ + private $sql_mode = 'NO_ENGINE_SUBSTITUTION'; + + /** + * MySQL-compatible session character set state. + * + * @var string + */ + private $charset = self::DEFAULT_MYSQL_CHARSET; + + /** + * MySQL-compatible session collation state. + * + * @var string + */ + private $collation = self::DEFAULT_MYSQL_COLLATION; + + /** + * Narrow in-memory procedure registry for WordPress mysqli compatibility tests. + * + * @var array + */ + private $procedures = array(); + /** * Constructor. * @@ -132,6 +173,61 @@ public function get_insert_id() { return is_numeric( $insert_id ) ? (int) $insert_id : $insert_id; } + /** + * Set the emulated MySQL session SQL mode. + * + * @param string $sql_mode Comma-separated SQL mode string. + */ + public function set_sql_mode( string $sql_mode ): void { + $this->sql_mode = $sql_mode; + } + + /** + * Get the emulated MySQL session SQL mode. + * + * @return string Comma-separated SQL mode string. + */ + public function get_sql_mode(): string { + return $this->sql_mode; + } + + /** + * Set the emulated MySQL session charset/collation. + * + * @param string $charset MySQL charset. + * @param string|null $collation Optional MySQL collation. + */ + public function set_charset( string $charset, ?string $collation = null ): void { + if ( 'default' === $this->normalize_mysql_charset_name( $charset ) ) { + $this->charset = self::DEFAULT_MYSQL_CHARSET; + $this->collation = self::DEFAULT_MYSQL_COLLATION; + return; + } + + $this->charset = $this->normalize_mysql_charset_name( $charset ); + $this->collation = null === $collation || '' === $collation + ? $this->get_default_mysql_collation_for_charset( $this->charset ) + : $this->normalize_mysql_collation_name( $collation ); + } + + /** + * Get the emulated MySQL session charset. + * + * @return string MySQL charset. + */ + public function get_charset(): string { + return $this->charset; + } + + /** + * Get the emulated MySQL session collation. + * + * @return string MySQL collation. + */ + public function get_collation(): string { + return $this->collation; + } + /** * Execute a query. * @@ -149,10 +245,81 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } - if ( $this->is_create_table_query( $query ) ) { - return $this->execute_postgresql_statements( - ( new WP_PostgreSQL_Create_Table_Translator() )->translate_schema( $query ) + if ( $this->apply_mysql_set_names_query( $query ) ) { + $this->last_result = 0; + return $this->last_result; + } + + $procedure_result = $this->handle_mysql_procedure_query( $query, $fetch_mode, ...$fetch_mode_args ); + if ( null !== $procedure_result ) { + return $procedure_result; + } + + $sql_mode_variable = $this->get_sql_mode_select_variable( $query ); + if ( null !== $sql_mode_variable ) { + $this->last_result = array( (object) array( $sql_mode_variable => $this->sql_mode ) ); + $this->last_column_meta = array( + array( + 'name' => $sql_mode_variable, + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => $sql_mode_variable, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ); + return $this->last_result; + } + + $show_variables_query = $this->get_show_variables_query( $query ); + if ( null !== $show_variables_query ) { + return $this->execute_show_variables_query( $show_variables_query, $fetch_mode, ...$fetch_mode_args ); + } + + if ( $this->is_found_rows_query( $query ) ) { + $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); + $this->last_column_meta = array( + array( + 'name' => 'FOUND_ROWS()', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'FOUND_ROWS()', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 0, + 'mysqli:type' => 8, + 'len' => 20, + 'precision' => 0, + 'native_type' => 'integer', + ), ); + return $this->last_result; + } + + if ( $this->is_create_table_query( $query ) ) { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $result = $this->execute_postgresql_statements( $translator->translate_schema( $query ) ); + $this->store_mysql_schema_metadata( $query ); + return $result; + } + + $alter_query = $this->translate_mysql_dbdelta_alter_table_query( $query ); + if ( null !== $alter_query ) { + $result = $this->execute_postgresql_statements( $alter_query['statements'] ); + $this->apply_mysql_dbdelta_alter_metadata( $alter_query['metadata'] ); + return $result; + } + + $drop_query = $this->translate_mysql_drop_table_query( $query ); + if ( null !== $drop_query ) { + $result = $this->execute_postgresql_statements( $drop_query['statements'] ); + $this->delete_mysql_schema_metadata_for_tables( $drop_query['tables'] ); + return $result; } $describe_table_name = $this->get_describe_table_name( $query ); @@ -170,34 +337,104 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo ); } + $show_columns_query = $this->get_show_columns_query( $query ); + if ( null !== $show_columns_query ) { + return $this->execute_show_columns_query( + $show_columns_query['schema'], + $show_columns_query['table'], + $show_columns_query['full'], + $show_columns_query['like'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_index_query = $this->get_show_index_query( $query ); + if ( null !== $show_index_query ) { + return $this->execute_show_index_query( + $show_index_query['table'], + $show_index_query['key_name'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $translated_for_postgresql = false; + $translated_query = $this->translate_wordpress_options_regexp_delete_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_wordpress_expired_transients_delete_query( $query ); + if ( null !== $translated_query ) { + return $this->execute_postgresql_statements( array( $translated_query ) ); } $translated_query = $this->translate_simple_mysql_delete_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; } $translated_query = $this->translate_wordpress_options_upsert_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; + } + + $replace_return_value = null; + $replace_query = $this->translate_simple_mysql_replace_query( $query ); + if ( null !== $replace_query ) { + if ( null !== $replace_query['conflict_column'] ) { + $replace_return_value = $this->replace_conflict_exists( + $replace_query['table_name'], + $replace_query['conflict_column'], + $replace_query['conflict_value'] + ) ? 2 : 1; + } + $query = $replace_query['sql']; + $translated_for_postgresql = true; } $translated_query = $this->translate_simple_mysql_insert_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; } $translated_query = $this->translate_simple_mysql_update_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; } $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_distinct_order_by_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $is_sql_calc_found_rows_query = false; + $translated_query = $this->translate_sql_calc_found_rows_select_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $is_sql_calc_found_rows_query = true; + $translated_for_postgresql = true; + } + + if ( ! $translated_for_postgresql ) { + $translated_query = $this->translate_mysql_compatible_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } } $stmt = $this->connection->query( $query ); @@ -209,9 +446,15 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $stmt->columnCount() > 0 ) { $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + if ( $is_sql_calc_found_rows_query ) { + $this->last_found_rows = count( $this->last_result ); + } } else { $this->last_column_meta = array(); $this->last_result = $stmt->rowCount(); + if ( null !== $replace_return_value ) { + $this->last_result = $replace_return_value; + } } return $this->last_result; @@ -238,157 +481,1852 @@ private function execute_postgresql_statements( array $statements ) { } /** - * Get the table name from a supported MySQL DESCRIBE/DESC statement. + * Apply a supported MySQL SET NAMES statement to the emulated session. * * @param string $query MySQL query. - * @return string|null Table name, or null when the statement is unsupported. + * @return bool Whether the query was handled. */ - private function get_describe_table_name( string $query ): ?string { + private function apply_mysql_set_names_query( string $query ): bool { $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 - ) + ! 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 null; + return false; } - $table_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); - if ( null === $table_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { - return null; + $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; } - return $table_name; + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return false; + } + + $this->set_charset( $charset, $collation ); + return true; } /** - * Parse a supported MySQL SHOW TABLES statement. - * - * @param string $query MySQL query. - * @return array{full: bool, like: string|null}|null SHOW TABLES options, or null when unsupported. + * Create the MySQL schema metadata tables used by dbDelta emulation. */ - private function get_show_tables_query( string $query ): ?array { - $tokens = $this->get_mysql_tokens( $query ); - if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { - return null; - } + private function ensure_mysql_schema_metadata_tables(): void { + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + column_type TEXT NOT NULL, + character_set_name TEXT, + collation_name TEXT, + is_nullable TEXT NOT NULL, + column_default TEXT, + extra TEXT NOT NULL, + PRIMARY KEY (table_schema, table_name, column_name) + )', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ) + ); - $position = 1; - $is_full = false; - if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { - $is_full = true; - ++$position; - } + $this->ensure_mysql_column_metadata_column( 'character_set_name', 'TEXT' ); + $this->ensure_mysql_column_metadata_column( 'collation_name', 'TEXT' ); + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + key_name TEXT NOT NULL, + index_ordinal INTEGER NOT NULL, + seq_in_index INTEGER NOT NULL, + column_name TEXT NOT NULL, + non_unique TEXT NOT NULL, + index_type TEXT NOT NULL, + sub_part TEXT, + nullable TEXT NOT NULL, + PRIMARY KEY (table_schema, table_name, key_name, seq_in_index) + )', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ) + ); + } - if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $position ]->id ) { - return null; + /** + * Add a MySQL column metadata field when upgrading an existing side table. + * + * @param string $column_name Column name. + * @param string $column_type Column type SQL. + */ + private function ensure_mysql_column_metadata_column( string $column_name, string $column_type ): void { + if ( $this->mysql_column_metadata_column_exists( $column_name ) ) { + return; } - ++$position; - $like = null; - if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { - if ( - ! isset( $tokens[ $position + 1 ] ) - || ( - WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id - && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + $this->connection->query( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote_identifier( $column_name ), + $column_type + ) + ); + } + + /** + * Check whether a MySQL column metadata field exists. + * + * @param string $column_name Column name. + * @return bool Whether the column exists. + */ + private function mysql_column_metadata_column_exists( string $column_name ): bool { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'sqlite' === $driver_name ) { + $stmt = $this->connection->query( + sprintf( + 'PRAGMA table_info(%s)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) ) - ) { - return null; - } + ); - $like = $tokens[ $position + 1 ]->get_value(); - $position += 2; - } + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $column ) { + if ( isset( $column['name'] ) && $column_name === $column['name'] ) { + return true; + } + } - if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { - return null; + return false; } - return array( - 'full' => $is_full, - 'like' => $like, + $stmt = $this->connection->query( + 'SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? + AND column_name = ? + )', + array( self::MYSQL_COLUMN_METADATA_TABLE, $column_name ) ); + + return (bool) $stmt->fetchColumn(); } /** - * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. + * Store MySQL-facing schema metadata for translated CREATE TABLE statements. * - * @param string $table_name Table name. - * @param int $fetch_mode PDO fetch mode. - * @param array ...$fetch_mode_args Additional fetch mode arguments. - * @return mixed DESCRIBE result rows. + * @param string $query MySQL CREATE TABLE query. */ - private function execute_describe_query( string $table_name, $fetch_mode, ...$fetch_mode_args ) { - $sql = $this->get_describe_catalog_query(); - $params = array( 'public', $table_name ); - $stmt = $this->connection->query( $sql, $params ); + public function store_mysql_schema_metadata( string $query ): void { + $this->ensure_mysql_schema_metadata_tables(); - $this->last_postgresql_queries[] = array( - 'sql' => $sql, - 'params' => $params, - ); - $this->last_column_meta = $this->normalize_column_meta( $stmt ); - $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query, true ); + foreach ( $metadata_tables as $metadata ) { + $table_schema = 'public'; + $table_name = $metadata['table_name']; - return $this->last_result; + $this->delete_mysql_schema_metadata_for_tables( array( $table_name ) ); + + $column_nullable = array(); + foreach ( $metadata['columns'] as $column ) { + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + $column_nullable[ strtolower( $column['name'] ) ] = $column['nullable'] ?? 'YES'; + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + } + } } /** - * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. + * Delete stored MySQL schema metadata for dropped tables. * - * @param bool $is_full Whether this is SHOW FULL TABLES. - * @param string|null $like Optional MySQL LIKE pattern. - * @param int $fetch_mode PDO fetch mode. - * @param array ...$fetch_mode_args Additional fetch mode arguments. - * @return mixed SHOW TABLES result rows. + * @param string[] $table_names Table names. */ - private function execute_show_tables_query( bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { - $table_column = $this->connection->quote_identifier( 'Tables_in_' . $this->db_name ); - $sql = sprintf( - 'SELECT table_name AS %s%s -FROM information_schema.tables -WHERE table_schema = ? - AND table_type IN (\'BASE TABLE\', \'VIEW\')', - $table_column, - $is_full ? ', CASE WHEN table_type = \'VIEW\' THEN \'VIEW\' ELSE \'BASE TABLE\' END AS "Table_type"' : '' - ); - $params = array( 'public' ); + private function delete_mysql_schema_metadata_for_tables( array $table_names ): void { + if ( empty( $table_names ) ) { + return; + } - if ( null !== $like ) { - $sql .= " AND table_name LIKE ? ESCAPE '\\'"; - $params[] = $like; + $this->ensure_mysql_schema_metadata_tables(); + + foreach ( $table_names as $table_name ) { + $params = array( 'public', $table_name ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + $params + ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + $params + ); } + } - $sql .= ' -ORDER BY table_name'; + /** + * Apply metadata changes for a translated dbDelta ALTER TABLE statement. + * + * @param array $metadata ALTER metadata. + */ + private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = 'public'; + $table_name = $metadata['table']; + + if ( 'add_column' === $metadata['operation'] ) { + $column = $metadata['column']; + $column['ordinal'] = $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); + $column_nullable = array( strtolower( $column['name'] ) => $column['nullable'] ?? 'YES' ); + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + } + return; + } - $stmt = $this->connection->query( $sql, $params ); + if ( 'change_column' === $metadata['operation'] ) { + $column = $metadata['column']; + $column['ordinal'] = $this->get_existing_mysql_column_ordinal( + $table_schema, + $table_name, + $metadata['old_column'] + ) ?? $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); + + $this->delete_mysql_column_metadata( $table_schema, $table_name, $metadata['old_column'] ); + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + $this->rename_mysql_index_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $column['name'] + ); + return; + } - $this->last_postgresql_queries[] = array( - 'sql' => $sql, - 'params' => $params, - ); - $this->last_column_meta = $this->normalize_column_meta( $stmt ); - $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + if ( 'add_index' === $metadata['operation'] ) { + $this->delete_mysql_index_metadata( $table_schema, $table_name, $metadata['index']['name'] ); + $this->insert_mysql_index_metadata( $table_schema, $table_name, $metadata['index'] ); + return; + } - return $this->last_result; + if ( 'set_default' === $metadata['operation'] ) { + $this->connection->query( + sprintf( + 'UPDATE %s SET column_default = ? WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $metadata['default'], $table_schema, $table_name, $metadata['column'] ) + ); + } } /** - * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. + * Insert or replace column metadata. * - * @return string SQL query. + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param array $column Column metadata. */ - private function get_describe_catalog_query(): string { - return 'SELECT - c.column_name AS "Field", - CASE - WHEN c.data_type = \'character varying\' THEN - \'varchar\' || CASE - WHEN c.character_maximum_length IS NULL THEN \'\' + private function insert_mysql_column_metadata( string $table_schema, string $table_name, array $column ): void { + $this->delete_mysql_column_metadata( $table_schema, $table_name, $column['name'] ); + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, column_name, ordinal_position, column_type, character_set_name, collation_name, is_nullable, column_default, extra) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $column['name'], + $column['ordinal'], + $column['type'], + $column['charset'] ?? null, + $column['collation'] ?? null, + $column['nullable'] ?? 'YES', + $column['default'] ?? null, + $column['extra'] ?? '', + ) + ); + } + + /** + * Delete metadata for one column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + */ + private function delete_mysql_column_metadata( string $table_schema, string $table_name, string $column_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + } + + /** + * Insert MySQL SHOW INDEX metadata rows for an index. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param array $index Index metadata. + * @param array|null $column_nullable Optional nullable metadata keyed by lowercase column. + */ + private function insert_mysql_index_metadata( + string $table_schema, + string $table_name, + array $index, + ?array $column_nullable = null + ): void { + $column_nullable = $column_nullable ?? array(); + + foreach ( $index['columns'] as $column ) { + $is_nullable = $column_nullable[ strtolower( $column['column_name'] ) ] + ?? $this->get_mysql_column_nullable( $table_schema, $table_name, $column['column_name'] ); + + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, key_name, index_ordinal, seq_in_index, column_name, non_unique, index_type, sub_part, nullable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $index['name'], + $index['ordinal'], + $column['seq_in_index'], + $column['column_name'], + $index['non_unique'], + $index['index_type'], + null === $column['sub_part'] ? null : (string) $column['sub_part'], + 'NO' === $is_nullable ? '' : 'YES', + ) + ); + } + } + + /** + * Delete metadata rows for one index. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $index_name Index name. + */ + private function delete_mysql_index_metadata( string $table_schema, string $table_name, string $index_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $index_name ) + ); + } + + /** + * Rename index column metadata after ALTER TABLE CHANGE COLUMN. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param string $new_column_name New column name. + */ + private function rename_mysql_index_column_metadata( + string $table_schema, + string $table_name, + string $old_column_name, + string $new_column_name + ): void { + if ( $old_column_name === $new_column_name ) { + return; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET column_name = ? WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $new_column_name, $table_schema, $table_name, $old_column_name ) + ); + } + + /** + * Get the next stored column ordinal for a table. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_column_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(ordinal_position), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Get an existing stored column ordinal. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return int|null Existing ordinal, or null. + */ + private function get_existing_mysql_column_ordinal( string $table_schema, string $table_name, string $column_name ): ?int { + $stmt = $this->connection->query( + sprintf( + 'SELECT ordinal_position FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $ordinal = $stmt->fetchColumn(); + return false === $ordinal ? null : (int) $ordinal; + } + + /** + * Get stored nullable metadata for an index column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string MySQL nullable value. + */ + private function get_mysql_column_nullable( string $table_schema, string $table_name, string $column_name ): string { + $stmt = $this->connection->query( + sprintf( + 'SELECT is_nullable FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $nullable = $stmt->fetchColumn(); + return false === $nullable ? 'YES' : (string) $nullable; + } + + /** + * Translate supported dbDelta ALTER TABLE statements to PostgreSQL. + * + * @param string $query MySQL ALTER TABLE query. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_alter_table_query( string $query ): ?array { + if ( ! preg_match( '/^\s*ALTER\s+TABLE\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { + return null; + } + + $table_name = '' !== ( $matches['table_quoted'] ?? '' ) ? $matches['table_quoted'] : $matches['table']; + $clause = $this->trim_mysql_statement_fragment( $matches['clause'] ); + + if ( preg_match( '/^CHANGE\s+(?:COLUMN\s+)?(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+(?P.+)$/is', $clause, $change_matches ) ) { + $old_column = '' !== ( $change_matches['old_quoted'] ?? '' ) ? $change_matches['old_quoted'] : $change_matches['old']; + $column = $this->translate_mysql_column_definition_fragment( $change_matches['definition'] ); + if ( null === $column ) { + return null; + } + + $new_column = $column['metadata']['name']; + $statements = array(); + if ( $old_column !== $new_column ) { + $statements[] = sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $old_column ), + $this->connection->quote_identifier( $new_column ) + ); + } + + $column_type = $this->get_translated_column_type_from_definition_line( $column['sql'] ); + $preserve_existing_identity = $this->should_preserve_existing_identity_integer_column_change( + 'public', + $table_name, + $old_column, + $column['metadata'] + ); + if ( '' !== $column_type && ! $preserve_existing_identity ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s TYPE %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + $column_type + ); + } + + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s NOT NULL', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + 'NO' === ( $column['metadata']['nullable'] ?? 'YES' ) ? 'SET' : 'DROP' + ); + + $default_sql = $this->get_translated_column_default_from_definition_line( $column['sql'] ); + if ( ! $preserve_existing_identity ) { + if ( null !== $default_sql ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + $default_sql + ); + } else { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ) + ); + } + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'change_column', + 'table' => $table_name, + 'old_column' => $old_column, + 'column' => $column['metadata'], + ), + ); + } + + if ( preg_match( '/^ADD\s+COLUMN\s+(?P.+)$/is', $clause, $add_column_matches ) ) { + $column = $this->translate_mysql_column_definition_fragment( $add_column_matches['definition'] ); + if ( null === $column ) { + return null; + } + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $column['sql'] + ), + ), + 'metadata' => array( + 'operation' => 'add_column', + 'table' => $table_name, + 'column' => $column['metadata'], + ), + ); + } + + if ( preg_match( '/^ADD\s+(?P(?:PRIMARY\s+KEY|(?:UNIQUE\s+)?(?:FULLTEXT\s+|SPATIAL\s+)?(?:KEY|INDEX))\b.+)$/is', $clause, $add_index_matches ) ) { + $index = $this->translate_mysql_index_definition_fragment( $table_name, $add_index_matches['definition'] ); + if ( null === $index ) { + return null; + } + + return array( + 'statements' => $index['statements'], + 'metadata' => array( + 'operation' => 'add_index', + 'table' => $table_name, + 'index' => $index['metadata'], + ), + ); + } + + if ( preg_match( '/^ALTER\s+COLUMN\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+SET\s+DEFAULT\s+(?P.+)$/is', $clause, $default_matches ) ) { + $column_name = '' !== ( $default_matches['column_quoted'] ?? '' ) ? $default_matches['column_quoted'] : $default_matches['column']; + $default = $this->translate_mysql_default_fragment( $default_matches['default'] ); + if ( null === $default ) { + return null; + } + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $default['sql'] + ), + ), + 'metadata' => array( + 'operation' => 'set_default', + 'table' => $table_name, + 'column' => $column_name, + 'default' => $default['metadata'], + ), + ); + } + + return null; + } + + /** + * Check whether an AUTO_INCREMENT CHANGE COLUMN should leave PostgreSQL identity DDL untouched. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column Existing column name. + * @param array $column Replacement MySQL metadata. + * @return bool Whether the physical type/default changes should be metadata-only. + */ + private function should_preserve_existing_identity_integer_column_change( + string $table_schema, + string $table_name, + string $old_column, + array $column + ): bool { + if ( 'auto_increment' !== strtolower( (string) ( $column['extra'] ?? '' ) ) ) { + return false; + } + + if ( ! $this->is_mysql_integer_family_column_type( (string) ( $column['type'] ?? '' ) ) ) { + return false; + } + + $existing = $this->get_existing_dbdelta_column_identity_metadata( $table_schema, $table_name, $old_column ); + if ( null === $existing || ! $this->is_existing_dbdelta_column_identity( $existing ) ) { + return false; + } + + $existing_mysql_type = (string) ( $existing['mysql_column_type'] ?? '' ); + if ( '' !== $existing_mysql_type ) { + return $this->is_mysql_integer_family_column_type( $existing_mysql_type ); + } + + return $this->is_postgresql_integer_family_data_type( (string) ( $existing['data_type'] ?? '' ) ); + } + + /** + * Get catalog and MySQL metadata for an existing dbDelta column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return array|null Existing column metadata, or null. + */ + private function get_existing_dbdelta_column_identity_metadata( string $table_schema, string $table_name, string $column_name ): ?array { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT + c.data_type, + c.is_identity, + c.column_default, + cm.column_type AS mysql_column_type, + cm.extra AS mysql_extra + FROM information_schema.columns c + LEFT JOIN %s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name + WHERE c.table_schema = ? + AND c.table_name = ? + AND c.column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + return false === $row ? null : $row; + } + + /** + * Check whether existing metadata describes a PostgreSQL identity column. + * + * @param array $metadata Existing column metadata. + * @return bool Whether the column is identity/auto_increment. + */ + private function is_existing_dbdelta_column_identity( array $metadata ): bool { + if ( 'auto_increment' === strtolower( (string) ( $metadata['mysql_extra'] ?? '' ) ) ) { + return true; + } + + if ( 'YES' === strtoupper( (string) ( $metadata['is_identity'] ?? '' ) ) ) { + return true; + } + + $column_default = ltrim( (string) ( $metadata['column_default'] ?? '' ) ); + return 0 === stripos( $column_default, 'nextval(' ); + } + + /** + * Check whether a MySQL column type is part of the integer family. + * + * @param string $column_type MySQL column type. + * @return bool Whether the type is integer-like. + */ + private function is_mysql_integer_family_column_type( string $column_type ): bool { + $column_type = strtolower( trim( $column_type ) ); + $column_type = preg_replace( '/\s+unsigned\b/i', '', $column_type ); + $column_type = trim( (string) $column_type ); + + return (bool) preg_match( '/^(?:bigint|int|integer|mediumint|smallint|tinyint)(?:\(\d+\))?$/', $column_type ); + } + + /** + * Check whether a PostgreSQL catalog data type is integer-like. + * + * @param string $data_type PostgreSQL information_schema data_type. + * @return bool Whether the type is integer-like. + */ + private function is_postgresql_integer_family_data_type( string $data_type ): bool { + return in_array( strtolower( trim( $data_type ) ), array( 'bigint', 'integer', 'smallint' ), true ); + } + + /** + * Translate supported DROP TABLE statements and expose dropped table names. + * + * @param string $query MySQL DROP TABLE query. + * @return array{statements: string[], tables: string[]}|null Translation, or null when unsupported. + */ + private function translate_mysql_drop_table_query( string $query ): ?array { + if ( ! preg_match( '/^\s*DROP\s+(?:TEMPORARY\s+)?TABLE\s+(IF\s+EXISTS\s+)?(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { + return null; + } + + $table_names = $this->parse_mysql_identifier_csv( $matches['tables'] ); + if ( null === $table_names ) { + return null; + } + + return array( + 'statements' => array( + sprintf( + 'DROP TABLE %s%s', + '' !== $matches[1] ? 'IF EXISTS ' : '', + implode( + ', ', + array_map( + array( $this->connection, 'quote_identifier' ), + $table_names + ) + ) + ), + ), + 'tables' => $table_names, + ); + } + + /** + * Translate a MySQL column definition fragment via the CREATE TABLE translator. + * + * @param string $definition MySQL column definition. + * @return array{sql: string, metadata: array}|null Translated column, or null when unsupported. + */ + private function translate_mysql_column_definition_fragment( string $definition ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $wrapper = 'CREATE TABLE __wp_dbdelta_column (' . $definition . ')'; + + $statements = $translator->translate_schema( $wrapper ); + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['columns'][0] ) ) { + return null; + } + + return array( + 'sql' => $this->get_first_translated_create_table_definition( $statements[0] ), + 'metadata' => $metadata[0]['columns'][0], + ); + } + + /** + * Translate a MySQL index definition fragment via the CREATE TABLE translator. + * + * @param string $table_name Table name receiving the index. + * @param string $definition MySQL index definition. + * @return array{statements: string[], metadata: array}|null Translated index, or null when unsupported. + */ + private function translate_mysql_index_definition_fragment( string $table_name, string $definition ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $wrapper = 'CREATE TABLE __wp_dbdelta_index (__wp_dummy int, ' . $definition . ')'; + + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['indexes'][0] ) ) { + return null; + } + + $index = $metadata[0]['indexes'][0]; + $columns = array(); + foreach ( $index['columns'] as $column ) { + $columns[] = $this->connection->quote_identifier( $column['column_name'] ); + } + + if ( 'PRIMARY' === strtoupper( $index['name'] ) ) { + $statement = sprintf( + 'ALTER TABLE %s ADD PRIMARY KEY (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + } else { + $statement = sprintf( + 'CREATE %sINDEX %s ON %s (%s)', + '0' === $index['non_unique'] ? 'UNIQUE ' : '', + $this->connection->quote_identifier( $table_name . '__' . $index['name'] ), + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + } + + return array( + 'statements' => array( $statement ), + 'metadata' => $index, + ); + } + + /** + * Extract the first definition from a translated CREATE TABLE statement. + * + * @param string $create_table_sql Translated CREATE TABLE statement. + * @return string First definition line. + */ + private function get_first_translated_create_table_definition( string $create_table_sql ): string { + if ( ! preg_match( "/\\(\\n (?P.*)\\n\\)\\z/s", $create_table_sql, $matches ) ) { + throw new InvalidArgumentException( 'Translated CREATE TABLE statement has an unexpected shape.' ); + } + + $definitions = explode( ",\n ", $matches['definitions'] ); + return $definitions[0]; + } + + /** + * Extract PostgreSQL type SQL from a translated column definition line. + * + * @param string $definition_line Translated column definition line. + * @return string PostgreSQL type SQL. + */ + private function get_translated_column_type_from_definition_line( string $definition_line ): string { + if ( ! preg_match( '/^"(?:""|[^"])+"\s+(?P.+)$/s', $definition_line, $matches ) ) { + return ''; + } + + $definition = $matches['definition']; + $stop_at = strlen( $definition ); + foreach ( array( ' GENERATED ', ' NOT NULL', ' DEFAULT ' ) as $marker ) { + $position = stripos( $definition, $marker ); + if ( false !== $position && $position < $stop_at ) { + $stop_at = $position; + } + } + + return trim( substr( $definition, 0, $stop_at ) ); + } + + /** + * Extract PostgreSQL DEFAULT SQL from a translated column definition line. + * + * @param string $definition_line Translated column definition line. + * @return string|null Default SQL, or null when absent. + */ + private function get_translated_column_default_from_definition_line( string $definition_line ): ?string { + $definition_line = preg_replace( + '/\s+GENERATED\s+BY\s+DEFAULT\s+AS\s+IDENTITY\b/i', + '', + $definition_line + ); + + if ( ! preg_match( '/\sDEFAULT\s+(?P.+)$/is', $definition_line, $matches ) ) { + return null; + } + + return trim( $matches['default'] ); + } + + /** + * Translate a simple MySQL DEFAULT fragment. + * + * @param string $fragment Default expression fragment. + * @return array{sql: string, metadata: string|null}|null Translated default, or null when unsupported. + */ + private function translate_mysql_default_fragment( string $fragment ): ?array { + $fragment = $this->trim_mysql_statement_fragment( $fragment ); + $tokens = $this->get_mysql_tokens( $fragment ); + $end = $this->get_mysql_statement_end_position( $tokens, 0 ); + if ( 1 !== $end ) { + return null; + } + + $token = $tokens[0]; + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + return array( + 'sql' => 'NULL', + 'metadata' => null, + ); + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return array( + 'sql' => $this->translate_mysql_token_to_postgresql( $token ), + 'metadata' => $token->get_value(), + ); + } + + return null; + } + + /** + * Parse a comma-separated list of simple MySQL identifiers. + * + * @param string $identifiers Identifier list. + * @return string[]|null Identifier values, or null when unsupported. + */ + private function parse_mysql_identifier_csv( string $identifiers ): ?array { + $values = array(); + + foreach ( explode( ',', $identifiers ) as $identifier ) { + $identifier = trim( $identifier ); + if ( preg_match( '/^`([^`]+)`$/', $identifier, $matches ) ) { + $values[] = $matches[1]; + continue; + } + + if ( preg_match( '/^[A-Za-z0-9_]+$/', $identifier ) ) { + $values[] = $identifier; + continue; + } + + return null; + } + + return $values; + } + + /** + * Trim a MySQL statement fragment. + * + * @param string $fragment SQL fragment. + * @return string Trimmed fragment. + */ + private function trim_mysql_statement_fragment( string $fragment ): string { + return rtrim( trim( $fragment ), "; \t\n\r\0\x0B" ); + } + + /** + * Emulate the narrow stored procedure surface used by WordPress tests. + * + * @param string $query MySQL query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed|null Query result, or null when the query is not a supported procedure statement. + */ + private function handle_mysql_procedure_query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + if ( preg_match( '/^\s*DROP\s+PROCEDURE\s+IF\s+EXISTS\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { + unset( $this->procedures[ strtolower( $matches[1] ) ] ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( preg_match( '/^\s*CREATE\s+PROCEDURE\s+`?([A-Za-z0-9_]+)`?\s*\(\s*\)\s+BEGIN\s+(.*?)\s*;\s*END\s*;?\s*$/is', $query, $matches ) ) { + $this->procedures[ strtolower( $matches[1] ) ] = trim( $matches[2] ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( preg_match( '/^\s*SHOW\s+CREATE\s+PROCEDURE\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { + $name = strtolower( $matches[1] ); + if ( ! isset( $this->procedures[ $name ] ) ) { + $this->last_result = array(); + $this->last_column_meta = array(); + return $this->last_result; + } + + $this->last_result = array( + (object) array( + 'Procedure' => $matches[1], + 'sql_mode' => $this->sql_mode, + 'Create Procedure' => 'CREATE PROCEDURE `' . $matches[1] . '`() BEGIN ' . $this->procedures[ $name ] . '; END', + ), + ); + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( preg_match( '/^\s*CALL\s+`?([A-Za-z0-9_]+)`?\s*(?:\(\s*\))?\s*;?\s*$/i', $query, $matches ) ) { + $name = strtolower( $matches[1] ); + if ( ! isset( $this->procedures[ $name ] ) ) { + return null; + } + + return $this->query( $this->procedures[ $name ], $fetch_mode, ...$fetch_mode_args ); + } + + return null; + } + + /** + * Get the table name from a supported MySQL DESCRIBE/DESC statement. + * + * @param string $query MySQL query. + * @return string|null Table name, or null when the statement is unsupported. + */ + private function get_describe_table_name( string $query ): ?string { + $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; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); + if ( null === $table_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { + return null; + } + + return $table_name; + } + + /** + * Parse a supported MySQL SHOW TABLES statement. + * + * @param string $query MySQL query. + * @return array{full: bool, like: string|null}|null SHOW TABLES options, or null when unsupported. + */ + private function get_show_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $is_full = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $like = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + ) + ) { + return null; + } + + $like = $tokens[ $position + 1 ]->get_value(); + $position += 2; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return array( + 'full' => $is_full, + 'like' => $like, + ); + } + + /** + * Parse a supported MySQL SHOW VARIABLES statement. + * + * @param string $query MySQL query. + * @return array{type: string, pattern: string}|null SHOW VARIABLES options, or null when unsupported. + */ + private function get_show_variables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::VARIABLES_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( + isset( $tokens[2], $tokens[3] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[2]->id + && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[3]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[3]->id ) + && $this->is_at_mysql_query_end( $tokens, 4 ) + ) { + return array( + 'type' => 'like', + 'pattern' => strtolower( $tokens[3]->get_value() ), + ); + } + + if ( + isset( $tokens[2], $tokens[3], $tokens[4], $tokens[5] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[2]->id + && WP_MySQL_Lexer::IDENTIFIER === $tokens[3]->id + && 'variable_name' === strtolower( $tokens[3]->get_value() ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[4]->id + && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[5]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[5]->id ) + && $this->is_at_mysql_query_end( $tokens, 6 ) + ) { + return array( + 'type' => 'exact', + 'pattern' => strtolower( $tokens[5]->get_value() ), + ); + } + + return null; + } + + /** + * Parse a supported MySQL SHOW COLUMNS/SHOW FULL COLUMNS statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string, full: bool, like: string|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS statement. + */ + private function get_show_columns_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $is_full = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COLUMNS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + ++$position; + $table_reference = $this->get_show_columns_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $schema_name = $table_reference['schema'] ?? 'public'; + $table_name = $table_reference['table']; + $position = $table_reference['position']; + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + if ( null !== $table_reference['schema'] ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $position += 2; + } + + $like = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $like = $tokens[ $position + 1 ]->get_value(); + $position += 2; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + return array( + 'schema' => $schema_name, + 'table' => $table_name, + 'full' => $is_full, + 'like' => $like, + ); + } + + /** + * Parse a SHOW COLUMNS table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @return array{schema: string|null, table: string, position: int}|null Parsed reference, or null when unsupported. + */ + private function get_show_columns_table_reference( array $tokens, int $position ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + 'position' => $position, + ); + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + 'position' => $position + 2, + ); + } + + /** + * Parse a supported MySQL SHOW INDEX/SHOW INDEXES statement. + * + * @param string $query MySQL query. + * @return array{table: string, key_name: string|null}|null SHOW INDEX options, or null when unsupported. + */ + private function get_show_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( + WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::INDEXES_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[3] ?? null ); + if ( null === $table_name ) { + return null; + } + + $position = 4; + $key_name = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + $where_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( + null === $where_column + || 'key_name' !== strtolower( $where_column ) + || ! isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 2 ]->id + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 3 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 3 ]->id + ) + ) { + return null; + } + + $key_name = $tokens[ $position + 3 ]->get_value(); + $position += 4; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return array( + 'table' => $table_name, + 'key_name' => $key_name, + ); + } + + /** + * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. + * + * @param string $table_name Table name. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed DESCRIBE result rows. + */ + private function execute_describe_query( string $table_name, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $sql = $this->get_describe_catalog_query(); + $params = array( 'public', $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + + /** + * Execute a MySQL SHOW COLUMNS/SHOW FULL COLUMNS statement through PostgreSQL catalogs. + * + * @param string $schema_name Schema name. + * @param string $table_name Table name. + * @param bool $is_full Whether this is SHOW FULL COLUMNS. + * @param string|null $like Optional MySQL LIKE pattern. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW COLUMNS result rows. + */ + private function execute_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $sql = $this->get_show_columns_catalog_query( $is_full ); + $params = array( $schema_name, $table_name ); + + if ( null !== $like ) { + $sql .= " AND field_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + $sql .= ' +ORDER BY ordinal_position'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + + /** + * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. + * + * @param bool $is_full Whether this is SHOW FULL TABLES. + * @param string|null $like Optional MySQL LIKE pattern. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW TABLES result rows. + */ + private function execute_show_tables_query( bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + $table_column = $this->connection->quote_identifier( 'Tables_in_' . $this->db_name ); + $sql = sprintf( + 'SELECT table_name AS %s%s + FROM information_schema.tables + WHERE table_schema = ? + AND table_type IN (\'BASE TABLE\', \'VIEW\') + AND table_name NOT IN (%s, %s, %s)', + $table_column, + $is_full ? ', CASE WHEN table_type = \'VIEW\' THEN \'VIEW\' ELSE \'BASE TABLE\' END AS "Table_type"' : '', + $this->connection->quote( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_INDEX_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHARSET_METADATA_TABLE ) + ); + $params = array( 'public' ); + + if ( null !== $like ) { + $sql .= " AND table_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + $sql .= ' +ORDER BY table_name'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + + /** + * Execute a MySQL SHOW VARIABLES statement from emulated session state. + * + * @param array $show_variables_query SHOW VARIABLES options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW VARIABLES result rows. + */ + private function execute_show_variables_query( array $show_variables_query, $fetch_mode, ...$fetch_mode_args ) { + $variables = $this->get_mysql_session_variables(); + $rows = array(); + + foreach ( $variables as $variable_name => $value ) { + if ( + 'exact' === $show_variables_query['type'] + && $variable_name !== $show_variables_query['pattern'] + ) { + continue; + } + + if ( + 'like' === $show_variables_query['type'] + && ! $this->matches_mysql_like_pattern( $variable_name, $show_variables_query['pattern'] ) + ) { + continue; + } + + $rows[] = array( + 'Variable_name' => $variable_name, + 'Value' => $value, + ); + } + + $this->last_column_meta = array( + array( + 'name' => 'Variable_name', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Variable_name', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 64, + 'precision' => 0, + 'native_type' => 'string', + ), + array( + 'name' => 'Value', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Value', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + + /** + * Match a string against a MySQL LIKE pattern. + * + * @param string $value Value to check. + * @param string $pattern MySQL LIKE pattern. + * @return bool Whether the pattern matches. + */ + private function matches_mysql_like_pattern( string $value, string $pattern ): bool { + $regex = '/^'; + $length = strlen( $pattern ); + + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char && $i + 1 < $length ) { + ++$i; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + $regex .= '$/i'; + return 1 === preg_match( $regex, $value ); + } + + /** + * Get MySQL-compatible session variables exposed by SHOW VARIABLES. + * + * @return array Session variables keyed by lowercase name. + */ + private function get_mysql_session_variables(): array { + return array( + 'character_set_client' => $this->charset, + 'character_set_connection' => $this->charset, + 'character_set_results' => $this->charset, + 'character_set_database' => $this->charset, + 'character_set_server' => $this->charset, + 'collation_connection' => $this->collation, + 'collation_database' => $this->collation, + 'collation_server' => $this->collation, + ); + } + + /** + * Normalize a MySQL charset name. + * + * @param string $charset Charset name. + * @return string Normalized charset. + */ + private function normalize_mysql_charset_name( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Normalize a MySQL collation name. + * + * @param string $collation Collation name. + * @return string Normalized collation. + */ + private function normalize_mysql_collation_name( string $collation ): string { + $collation = strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $collation, 'utf8mb3_' ) ) { + return 'utf8_' . substr( $collation, strlen( 'utf8mb3_' ) ); + } + + return $collation; + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset name. + * @return string Collation name. + */ + private function get_default_mysql_collation_for_charset( string $charset ): string { + $charset = $this->normalize_mysql_charset_name( $charset ); + $collations = array( + 'ascii' => 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + ); + + return $collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Execute a MySQL SHOW INDEX/SHOW INDEXES statement through PostgreSQL catalogs. + * + * @param string $table_name Table name. + * @param string|null $key_name Optional MySQL Key_name filter. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW INDEX result rows. + */ + private function execute_show_index_query( string $table_name, ?string $key_name, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $sql = $this->get_show_index_catalog_query(); + $params = array( 'public', $table_name ); + + if ( null !== $key_name ) { + $sql .= ' +WHERE "Key_name" = ?'; + $params[] = $key_name; + } + + $sql .= ' +ORDER BY + "Key_name" = \'PRIMARY\' DESC, + "Non_unique" = \'0\' DESC, + "Index_type" = \'SPATIAL\' DESC, + "Index_type" = \'BTREE\' DESC, + "Index_type" = \'FULLTEXT\' DESC, + postgresql_index_oid, + CAST("Seq_in_index" AS integer)'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + return $this->last_result; + } + + /** + * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. + * + * @return string SQL query. + */ + private function get_describe_catalog_query(): string { + $column_metadata_table = $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ); + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +catalog_columns AS ( + SELECT + c.column_name AS field_name, + COALESCE( + cm.column_type, + CASE + WHEN c.data_type = \'character varying\' THEN + \'varchar\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'character\' THEN + \'char\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'integer\' THEN \'int\' + WHEN c.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE c.data_type + END + ) AS column_type, + COALESCE(cm.is_nullable, c.is_nullable) AS is_nullable, + CASE + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + ) THEN \'MUL\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'PRIMARY KEY\' + AND kcu.column_name = c.column_name + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'UNIQUE\' + AND kcu.column_name = c.column_name + ) THEN \'UNI\' + ELSE \'\' + END AS column_key, + CASE + WHEN cm.column_name IS NOT NULL THEN cm.column_default + ELSE c.column_default + END AS column_default, + COALESCE( + cm.extra, + CASE + WHEN c.is_identity = \'YES\' THEN \'auto_increment\' + WHEN c.column_default LIKE \'nextval(%%\' THEN \'auto_increment\' + ELSE \'\' + END + ) AS column_extra, + c.ordinal_position + FROM requested_table rt + INNER JOIN information_schema.columns c + ON c.table_schema = rt.table_schema + AND c.table_name = rt.table_name + LEFT JOIN %1$s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name +), +metadata_columns AS ( + SELECT + cm.column_name AS field_name, + cm.column_type, + cm.is_nullable, + CASE + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + ) THEN \'MUL\' + ELSE \'\' + END AS column_key, + cm.column_default, + cm.extra AS column_extra, + cm.ordinal_position + FROM requested_table rt + INNER JOIN %1$s cm + ON cm.table_schema = rt.table_schema + AND cm.table_name = rt.table_name + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = cm.table_schema + AND c.table_name = cm.table_name + AND c.column_name = cm.column_name + ) +), +describe_rows AS ( + SELECT * FROM catalog_columns + UNION ALL + SELECT * FROM metadata_columns +) +SELECT + field_name AS "Field", + column_type AS "Type", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra" +FROM describe_rows +ORDER BY ordinal_position', + $column_metadata_table, + $index_metadata_table + ); + } + + /** + * Get the PostgreSQL catalog query backing MySQL SHOW COLUMNS/FULL COLUMNS. + * + * @param bool $is_full Whether the query should emit SHOW FULL COLUMNS fields. + * @return string SQL query. + */ + private function get_show_columns_catalog_query( bool $is_full ): string { + $column_metadata_table = $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ); + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + $type_expression = 'CASE + WHEN c.data_type = \'character varying\' THEN + \'varchar\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' END WHEN c.data_type = \'character\' THEN @@ -399,9 +2337,52 @@ private function get_describe_catalog_query(): string { WHEN c.data_type = \'integer\' THEN \'int\' WHEN c.data_type = \'timestamp without time zone\' THEN \'datetime\' ELSE c.data_type - END AS "Type", - c.is_nullable AS "Null", - CASE + END'; + + $catalog_collation_expression = 'CASE + WHEN cm.column_type IS NOT NULL THEN + CASE + WHEN LOWER(cm.column_type) LIKE \'char%\' + OR LOWER(cm.column_type) LIKE \'varchar%\' + OR LOWER(cm.column_type) LIKE \'%text%\' THEN COALESCE(cm.collation_name, c.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END + WHEN c.data_type IN (\'character varying\', \'character\', \'text\') THEN COALESCE(c.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END'; + + $metadata_collation_expression = 'CASE + WHEN LOWER(cm.column_type) LIKE \'char%\' + OR LOWER(cm.column_type) LIKE \'varchar%\' + OR LOWER(cm.column_type) LIKE \'%text%\' THEN COALESCE(cm.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END'; + + $catalog_key_expression = sprintf( + 'CASE + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + ) THEN \'MUL\' WHEN EXISTS ( SELECT 1 FROM information_schema.table_constraints tc @@ -429,17 +2410,266 @@ private function get_describe_catalog_query(): string { AND kcu.column_name = c.column_name ) THEN \'UNI\' ELSE \'\' - END AS "Key", - c.column_default AS "Default", - CASE + END', + $index_metadata_table + ); + + $metadata_key_expression = sprintf( + 'CASE + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + ) THEN \'MUL\' + ELSE \'\' + END', + $index_metadata_table + ); + + $catalog_extra_expression = 'CASE WHEN c.is_identity = \'YES\' THEN \'auto_increment\' WHEN c.column_default LIKE \'nextval(%\' THEN \'auto_increment\' ELSE \'\' - END AS "Extra" -FROM information_schema.columns c -WHERE c.table_schema = ? - AND c.table_name = ? -ORDER BY c.ordinal_position'; + END'; + + if ( $is_full ) { + $fields = 'field_name AS "Field", + column_type AS "Type", + collation_name AS "Collation", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra", + \'select,insert,update,references\' AS "Privileges", + \'\' AS "Comment"'; + } else { + $fields = 'field_name AS "Field", + column_type AS "Type", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra"'; + } + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +catalog_columns AS ( + SELECT + c.column_name AS field_name, + COALESCE(cm.column_type, %3$s) AS column_type, + %4$s AS collation_name, + COALESCE(cm.is_nullable, c.is_nullable) AS is_nullable, + %5$s AS column_key, + CASE + WHEN cm.column_name IS NOT NULL THEN cm.column_default + ELSE c.column_default + END AS column_default, + COALESCE(cm.extra, %7$s) AS column_extra, + c.ordinal_position + FROM requested_table rt + INNER JOIN information_schema.columns c + ON c.table_schema = rt.table_schema + AND c.table_name = rt.table_name + LEFT JOIN %1$s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name +), +metadata_columns AS ( + SELECT + cm.column_name AS field_name, + cm.column_type, + %8$s AS collation_name, + cm.is_nullable, + %6$s AS column_key, + cm.column_default, + cm.extra AS column_extra, + cm.ordinal_position + FROM requested_table rt + INNER JOIN %1$s cm + ON cm.table_schema = rt.table_schema + AND cm.table_name = rt.table_name + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = cm.table_schema + AND c.table_name = cm.table_name + AND c.column_name = cm.column_name + ) +), +show_columns_rows AS ( + SELECT * FROM catalog_columns + UNION ALL + SELECT * FROM metadata_columns +) +SELECT + %9$s +FROM show_columns_rows +WHERE 1 = 1', + $column_metadata_table, + $index_metadata_table, + $type_expression, + $catalog_collation_expression, + $catalog_key_expression, + $metadata_key_expression, + $catalog_extra_expression, + $metadata_collation_expression, + $fields + ); + } + + /** + * Get the PostgreSQL catalog query backing MySQL SHOW INDEX/SHOW INDEXES. + * + * @return string SQL query. + */ + private function get_show_index_catalog_query(): string { + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +metadata_exists AS ( + SELECT EXISTS ( + SELECT 1 + FROM %1$s im + INNER JOIN requested_table rt + ON rt.table_schema = im.table_schema + AND rt.table_name = im.table_name + ) AS has_metadata +), +metadata_index_rows AS ( + SELECT + im.table_name AS "Table", + im.non_unique AS "Non_unique", + im.key_name AS "Key_name", + CAST(im.seq_in_index AS text) AS "Seq_in_index", + im.column_name AS "Column_name", + \'A\' AS "Collation", + \'0\' AS "Cardinality", + im.sub_part AS "Sub_part", + NULL AS "Packed", + im.nullable AS "Null", + im.index_type AS "Index_type", + \'\' AS "Comment", + \'\' AS "Index_comment", + \'YES\' AS "Visible", + NULL AS "Expression", + im.index_ordinal AS postgresql_index_oid + FROM %1$s im + INNER JOIN requested_table rt + ON rt.table_schema = im.table_schema + AND rt.table_name = im.table_name +), +index_columns AS ( + SELECT + t.relname AS table_name, + CAST(idx.oid AS bigint) AS postgresql_index_oid, + idx.relname AS postgresql_index_name, + i.indisunique, + i.indisprimary, + am.amname AS access_method, + k.ordinality AS seq_in_index, + k.attnum, + a.attname AS column_name, + a.attnotnull, + CASE + WHEN 0 = k.attnum THEN pg_catalog.pg_get_indexdef(i.indexrelid, CAST(k.ordinality AS integer), true) + ELSE NULL + END AS expression + FROM pg_catalog.pg_class t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_index i + ON i.indrelid = t.oid + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + INNER JOIN pg_catalog.pg_am am + ON am.oid = idx.relam + CROSS JOIN LATERAL pg_catalog.unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = t.oid + AND a.attnum = k.attnum + INNER JOIN requested_table rt + ON rt.table_schema = n.nspname + AND rt.table_name = t.relname + WHERE k.ordinality <= i.indnkeyatts + AND i.indisvalid + AND i.indislive +), +catalog_index_rows AS ( + SELECT + table_name AS "Table", + CASE WHEN indisunique THEN \'0\' ELSE \'1\' END AS "Non_unique", + CASE + WHEN indisprimary THEN \'PRIMARY\' + WHEN postgresql_index_name LIKE table_name || \'__%%\' THEN SUBSTRING(postgresql_index_name FROM CHAR_LENGTH(table_name || \'__\') + 1) + ELSE postgresql_index_name + END AS "Key_name", + CAST(seq_in_index AS text) AS "Seq_in_index", + column_name AS "Column_name", + \'A\' AS "Collation", + \'0\' AS "Cardinality", + NULL AS "Sub_part", + NULL AS "Packed", + CASE + WHEN 0 = attnum OR attnotnull THEN \'\' + ELSE \'YES\' + END AS "Null", + UPPER(access_method) AS "Index_type", + \'\' AS "Comment", + \'\' AS "Index_comment", + \'YES\' AS "Visible", + expression AS "Expression", + postgresql_index_oid + FROM index_columns + WHERE NOT (SELECT has_metadata FROM metadata_exists) +), +show_index_rows AS ( + SELECT * FROM metadata_index_rows + UNION ALL + SELECT * FROM catalog_index_rows + ) + SELECT + "Table", + "Non_unique", + "Key_name", + "Seq_in_index", + "Column_name", + "Collation", + "Cardinality", + "Sub_part", + "Packed", + "Null", + "Index_type", + "Comment", + "Index_comment", + "Visible", + "Expression" + FROM show_index_rows', + $index_metadata_table + ); } /** @@ -518,6 +2748,24 @@ private function reset_query_state(): void { $this->last_postgresql_queries = array(); } + /** + * Translate MySQL DROP TEMPORARY TABLE cleanup statements. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_drop_temporary_table_query( string $query ): ?string { + if ( ! preg_match( '/^\s*DROP\s+TEMPORARY\s+TABLE\s+(IF\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { + return null; + } + + return sprintf( + 'DROP TABLE %s%s', + '' !== $matches[1] ? 'IF EXISTS ' : '', + $this->connection->quote_identifier( $matches[2] ) + ); + } + /** * Translate the WordPress options cleanup DELETE ... REGEXP query. * @@ -561,6 +2809,65 @@ private function translate_wordpress_options_regexp_delete_query( string $query ); } + /** + * Translate WordPress expired transient cleanup DELETE statements. + * + * Core emits a MySQL multi-table DELETE that removes both transient values + * and their timeout rows. PostgreSQL does not support that DELETE syntax, so + * this rewrites only the exact WordPress options-table shape to a CTE-backed + * single-table DELETE. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_wordpress_expired_transients_delete_query( string $query ): ?string { + $pattern = '/^\s*DELETE\s+a\s*,\s*b\s+FROM\s+([A-Za-z0-9_]+)\s+a\s*,\s*\1\s+b\s+WHERE\s+a\.option_name\s+LIKE\s+([\'"])([^\'"]+)\\2\s+AND\s+a\.option_name\s+NOT\s+LIKE\s+([\'"])([^\'"]+)\\4\s+AND\s+b\.option_name\s*=\s*CONCAT\s*\(\s*([\'"])([^\'"]+)\\6\s*,\s*SUBSTRING\s*\(\s*a\.option_name\s*,\s*([0-9]+)\s*\)\s*\)\s+AND\s+b\.option_value\s*<\s*([0-9]+)\s*;?\s*$/is'; + if ( ! preg_match( $pattern, $query, $matches ) ) { + return null; + } + + $table_name = $matches[1]; + $value_like = $matches[3]; + $timeout_like = $matches[5]; + $timeout_prefix = $matches[7]; + $substring_from = (int) $matches[8]; + $expires_before = $matches[9]; + + if ( + ! $this->is_wordpress_options_table_name( $table_name ) + || ! in_array( $timeout_prefix, array( '_transient_timeout_', '_site_transient_timeout_' ), true ) + || ( '_transient_timeout_' === $timeout_prefix && 12 !== $substring_from ) + || ( '_site_transient_timeout_' === $timeout_prefix && 17 !== $substring_from ) + ) { + return null; + } + + return sprintf( + 'WITH expired_transients AS ( + SELECT a.option_name AS value_name, b.option_name AS timeout_name + FROM %1$s a + INNER JOIN %1$s b + ON b.option_name = %2$s || SUBSTR(a.option_name, %3$d) + WHERE a.option_name LIKE %4$s ESCAPE %5$s + AND a.option_name NOT LIKE %6$s ESCAPE %5$s + AND CAST(b.option_value AS BIGINT) < %7$s +) +DELETE FROM %1$s +WHERE option_name IN ( + SELECT value_name FROM expired_transients + UNION + SELECT timeout_name FROM expired_transients +)', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote( $timeout_prefix ), + $substring_from, + $this->connection->quote( $value_like ), + $this->connection->quote( '\\' ), + $this->connection->quote( $timeout_like ), + $expires_before + ); + } + /** * Translate simple single-table MySQL DELETE statements to PostgreSQL. * @@ -683,6 +2990,164 @@ private function translate_wordpress_options_upsert_query( string $query ): ?str ); } + /** + * Translate simple single-row MySQL REPLACE statements to PostgreSQL. + * + * WordPress' wpdb::replace() emits a single VALUES row with an explicit + * column list. For rows with a known WordPress unique key, use PostgreSQL's + * ON CONFLICT update path and synthesize MySQL's affected-row count in + * query(). Without a known conflict column, fall back to a plain INSERT so + * PostgreSQL still reports normal constraint and length errors. + * + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when the query is unsupported. + */ + private function translate_simple_mysql_replace_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::REPLACE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = $this->parse_mysql_value_list( $tokens, $position ); + if ( null === $values || count( $columns ) !== count( $values ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $values ) + ); + + $conflict_column = $this->get_simple_replace_conflict_column( $table_name, $columns ); + if ( null === $conflict_column ) { + return array( + 'sql' => $sql, + 'table_name' => $table_name, + 'conflict_column' => null, + 'conflict_value' => null, + ); + } + + $conflict_index = null; + foreach ( $columns as $index => $column ) { + if ( strtolower( $column ) === strtolower( $conflict_column ) ) { + $conflict_index = $index; + break; + } + } + + if ( null === $conflict_index ) { + return null; + } + + $assignments = array(); + foreach ( $columns as $column ) { + $assignments[] = sprintf( + '%s = excluded.%s', + $this->connection->quote_identifier( $column ), + $this->connection->quote_identifier( $column ) + ); + } + + return array( + 'sql' => sprintf( + '%s ON CONFLICT (%s) DO UPDATE SET %s', + $sql, + $this->connection->quote_identifier( $conflict_column ), + implode( ', ', $assignments ) + ), + 'table_name' => $table_name, + 'conflict_column' => $conflict_column, + 'conflict_value' => $values[ $conflict_index ], + ); + } + + /** + * Check whether a simple REPLACE conflict target currently exists. + * + * @param string $table_name Table name. + * @param string $conflict_column Conflict column name. + * @param string $conflict_value Already translated SQL value. + * @return bool Whether the row exists. + */ + private function replace_conflict_exists( string $table_name, string $conflict_column, string $conflict_value ): bool { + if ( 'NULL' === strtoupper( $conflict_value ) ) { + return false; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE %s = %s LIMIT 1', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $conflict_column ), + $conflict_value + ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Choose the conflict column for a WordPress REPLACE statement. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return string|null Conflict column name, or null when unknown. + */ + private function get_simple_replace_conflict_column( string $table_name, array $columns ): ?string { + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + if ( $this->is_wordpress_options_table_name( $table_name ) && isset( $column_lookup['option_name'] ) ) { + return $column_lookup['option_name']; + } + + foreach ( + array( + 'id', + 'comment_id', + 'link_id', + 'option_id', + 'meta_id', + 'umeta_id', + 'term_id', + 'term_taxonomy_id', + ) as $candidate + ) { + if ( isset( $column_lookup[ $candidate ] ) ) { + return $column_lookup[ $candidate ]; + } + } + + return null; + } + /** * Translate simple single-row MySQL INSERT statements to PostgreSQL. * @@ -889,65 +3354,233 @@ private function translate_simple_mysql_select_query( string $query ): ?string { return null; } - $position = $from_position + 2; - $where_position = null; - $where_end = null; - $order_position = null; - - if ( $position < $select_end && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { - $where_position = $position; - $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position + 1, $select_end ); - $where_end = $order_position ?? $select_end; - - if ( - $where_position + 1 >= $where_end - || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) - ) { - return null; - } - - $position = $where_end; + $position = $from_position + 2; + $where_position = null; + $where_end = null; + $order_position = null; + + if ( $position < $select_end && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + $where_position = $position; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position + 1, $select_end ); + $where_end = $order_position ?? $select_end; + + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $position = $where_end; + } + + if ( $position < $select_end && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + $order_position = $position; + if ( ! $this->is_supported_simple_select_order_by_clause( $tokens, $order_position, $select_end ) ) { + return null; + } + + $position = $select_end; + } + + if ( $position !== $select_end ) { + return null; + } + + $sql = sprintf( + 'SELECT %s FROM %s', + $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $from_position ), + $this->translate_mysql_identifier_token_to_postgresql( $table_token ) + ); + + if ( null !== $where_position ) { + $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end + ); + } + + if ( null !== $order_position ) { + $sql .= ' ORDER BY ' . $this->translate_mysql_token_to_postgresql( $tokens[ $order_position + 2 ] ); + if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $select_end ) { + $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); + } + } + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate SELECT DISTINCT queries whose ORDER BY expression is not selected. + * + * PostgreSQL requires ORDER BY expressions in SELECT DISTINCT statements to + * appear in the projection. WordPress term queries commonly select only + * term_id while ordering by t.name; adding the order expression preserves the + * first result column used by wpdb::get_col(). + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_distinct_order_by_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $statement_end ); + if ( + null === $from_position + || null === $order_position + || $order_position + 2 >= $statement_end + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + ) { + return null; + } + + $order_end = $order_position + 3; + if ( + isset( $tokens[ $order_position + 3 ], $tokens[ $order_position + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $order_position + 3 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $order_position + 4 ] ) + ) { + $order_end = $order_position + 5; + } + + if ( $order_end > $statement_end || $order_position + 2 === $order_end ) { + return null; + } + + $after_order_expression = $order_end; + if ( + isset( $tokens[ $after_order_expression ] ) + && $after_order_expression < $statement_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $after_order_expression ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $after_order_expression ]->id + ) + ) { + ++$after_order_expression; + } + + if ( $after_order_expression !== $statement_end ) { + return null; + } + + $order_expression = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $order_position + 2, $order_end ); + $projection = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $from_position ); + + if ( false !== stripos( $projection, $order_expression ) ) { + return null; + } + + return sprintf( + 'SELECT DISTINCT %s, %s %s', + $projection, + $order_expression, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + + /** + * Translate WordPress SELECT SQL_CALC_FOUND_ROWS queries. + * + * PostgreSQL has no SQL_CALC_FOUND_ROWS modifier. WordPress issues these + * queries for pagination, followed by SELECT FOUND_ROWS(); this first pass + * executes the paginated query itself while preserving compatible clauses. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_sql_calc_found_rows_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return null; } - 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 ) ) { + $select_end = $statement_end; + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 2, $statement_end ); + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { return null; } + $select_end = $limit_position; + } - $position = $select_end; + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $select_end ); + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } - if ( $position !== $select_end ) { + return $sql; + } + + /** + * Apply conservative MySQL-to-PostgreSQL token compatibility rewrites. + * + * Complex WordPress queries often use PostgreSQL-compatible SQL except for + * MySQL identifier casing. This fallback quotes backticked and mixed-case + * identifiers without trying to emulate unsupported MySQL-only syntax. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when no compatibility rewrite applies. + */ + private function translate_mysql_compatible_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { return null; } - $sql = sprintf( - 'SELECT %s FROM %s', - $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $from_position ), - $this->translate_mysql_identifier_token_to_postgresql( $table_token ) - ); - - if ( null !== $where_position ) { - $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( - $tokens, - $where_position + 1, - $where_end - ); + if ( + ! in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::DELETE_SYMBOL, + WP_MySQL_Lexer::INSERT_SYMBOL, + WP_MySQL_Lexer::REPLACE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UPDATE_SYMBOL, + ), + true + ) + ) { + return null; } - if ( null !== $order_position ) { - $sql .= ' ORDER BY ' . $this->translate_mysql_token_to_postgresql( $tokens[ $order_position + 2 ] ); - if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $select_end ) { - $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); - } + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; } - if ( null !== $limit_position ) { - $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + if ( ! $this->needs_mysql_compatible_rewrite( $tokens, 0, $statement_end ) ) { + return null; } - return $sql; + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); } /** @@ -1012,6 +3645,61 @@ private function parse_mysql_identifier_list( array $tokens, int &$position ): ? return null; } + /** + * Parse a parenthesized single-row MySQL VALUES list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string[]|null Translated SQL values, or null when unsupported. + */ + private function parse_mysql_value_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = 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 ); + ++$position; + return $values; + } + + --$depth; + ++$position; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $value_start = $position + 1; + } + + ++$position; + } + + return null; + } + /** * Locate the top-level ON DUPLICATE KEY UPDATE clause. * @@ -1524,31 +4212,131 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in $previous_token_id = null; for ( $i = $start; $i < $end; $i++ ) { - $token = $tokens[ $i ]; - $fragment = $this->translate_mysql_token_to_postgresql( $token ); + $token = $tokens[ $i ]; + $fragment_token_id = $token->id; + $convert_expression = $this->translate_mysql_convert_using_to_postgresql( $tokens, $i, $end ); + if ( null !== $convert_expression ) { + $fragment = $convert_expression['sql']; + $fragment_token_id = $convert_expression['token_id']; + $i = $convert_expression['position']; + } else { + $fragment = $this->translate_mysql_token_to_postgresql( $token, $tokens[ $i + 1 ] ?? null ); + } if ( '' === $sql ) { $sql = $fragment; - } elseif ( $this->should_join_mysql_tokens_without_space( $previous_token_id, $token->id ) ) { + } elseif ( $this->should_join_mysql_tokens_without_space( $previous_token_id, $fragment_token_id ) ) { $sql .= $fragment; } else { $sql .= ' ' . $fragment; } - $previous_token_id = $token->id; + $previous_token_id = $fragment_token_id; } return $sql; } + /** + * Translate a MySQL CONVERT(expr USING charset) expression to PostgreSQL. + * + * PostgreSQL text is already stored in the database encoding, so the MySQL + * character-set conversion is represented by the inner expression. A directly + * attached MySQL COLLATE clause is dropped because PostgreSQL does not have + * MySQL collation names. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_convert_using_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_convert_using_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $final_position = $bounds['close']; + if ( + isset( $tokens[ $final_position + 1 ], $tokens[ $final_position + 2 ] ) + && $final_position + 2 < $end + && WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $final_position + 1 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $final_position + 2 ] ) + ) { + $final_position += 2; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => '(' . $expression_sql . ')', + 'token_id' => $tokens[ $bounds['expression_start'] ]->id, + 'position' => $final_position, + ); + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr USING charset) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_convert_using_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $using_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::USING_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $using_position + || $using_position <= $position + 2 + || $using_position + 2 !== $close_position + || ! $this->is_mysql_charset_token( $tokens[ $using_position + 1 ] ?? null ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $using_position, + 'close' => $close_position, + ); + } + /** * Translate a single MySQL token to a PostgreSQL fragment. * - * @param WP_MySQL_Token $token MySQL token. + * @param WP_MySQL_Token $token MySQL token. + * @param WP_MySQL_Token|null $next_token Next MySQL token, if known. * @return string PostgreSQL SQL fragment. */ - private function translate_mysql_token_to_postgresql( WP_MySQL_Token $token ): string { - if ( WP_MySQL_Lexer::IDENTIFIER === $token->id && $this->should_quote_bare_mysql_identifier( $token->get_value() ) ) { + private function translate_mysql_token_to_postgresql( WP_MySQL_Token $token, ?WP_MySQL_Token $next_token = null ): string { + if ( + WP_MySQL_Lexer::IDENTIFIER === $token->id + && $this->should_quote_bare_mysql_identifier( $token->get_value() ) + && ( null === $next_token || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $next_token->id ) + ) { return $this->connection->quote_identifier( $token->get_value() ); } @@ -1622,6 +4410,45 @@ private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token ): ?st return null; } + /** + * Check whether a token can represent a MySQL character set name. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is a supported charset token. + */ + private function is_mysql_charset_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return null !== $this->get_mysql_identifier_token_value( $token ) + || in_array( + $token->id, + array( + WP_MySQL_Lexer::ASCII_SYMBOL, + WP_MySQL_Lexer::BINARY_SYMBOL, + WP_MySQL_Lexer::DEFAULT_SYMBOL, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + ), + true + ); + } + + /** + * Get a MySQL charset/collation token value. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string Token value. + */ + private function get_mysql_charset_token_value( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + return 'default'; + } + + return $token->get_value(); + } + /** * Check whether a bare MySQL identifier needs PostgreSQL quoting. * @@ -1632,6 +4459,41 @@ private function should_quote_bare_mysql_identifier( string $identifier ): bool return strtolower( $identifier ) !== $identifier; } + /** + * Check whether a token range needs the compatibility rewrite. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether any token needs PostgreSQL compatibility rewriting. + */ + private function needs_mysql_compatible_rewrite( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + $token = $tokens[ $i ]; + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return true; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id ) { + return true; + } + + if ( null !== $this->get_mysql_convert_using_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( + WP_MySQL_Lexer::IDENTIFIER === $token->id + && $this->should_quote_bare_mysql_identifier( $token->get_value() ) + && ( ! isset( $tokens[ $i + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id ) + ) { + return true; + } + } + + return false; + } + /** * Check whether a query is a supported MySQL CREATE TABLE statement. * @@ -1663,15 +4525,25 @@ private function is_create_table_query( string $query ): bool { * @return bool Whether the query should use the install DDL translator. */ private function has_mysql_create_table_marker( array $tokens ): bool { - foreach ( $tokens as $token ) { + foreach ( $tokens as $position => $token ) { + if ( $this->is_mysql_create_table_charset_set_marker( $tokens, $position ) ) { + return true; + } + if ( in_array( $token->id, - array( - WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, - WP_MySQL_Lexer::CHARSET_SYMBOL, - WP_MySQL_Lexer::UNSIGNED_SYMBOL, - ), + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::COLLATE_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), true ) ) { @@ -1682,6 +4554,92 @@ private function has_mysql_create_table_marker( array $tokens ): bool { return false; } + /** + * Check whether tokens at a position form CHAR SET or CHARACTER SET. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether this is a MySQL charset marker. + */ + private function is_mysql_create_table_charset_set_marker( array $tokens, int $position ): bool { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return false; + } + + if ( WP_MySQL_Lexer::CHARACTER_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + + return WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $position ]->id + && in_array( strtolower( $tokens[ $position ]->get_bytes() ), array( 'char', 'character' ), true ); + } + + /** + * Get the selected MySQL sql_mode variable name from a supported query. + * + * @param string $query MySQL query. + * @return string|null Selected variable name, or null when unsupported. + */ + private function get_sql_mode_select_variable( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( + WP_MySQL_Lexer::IDENTIFIER === $tokens[2]->id + && 'sql_mode' === strtolower( $tokens[2]->get_value() ) + && $this->is_at_mysql_query_end( $tokens, 3 ) + ) { + return '@@' . $tokens[2]->get_value(); + } + + if ( + ! isset( $tokens[3], $tokens[4] ) + || ( + WP_MySQL_Lexer::SESSION_SYMBOL !== $tokens[2]->id + && WP_MySQL_Lexer::GLOBAL_SYMBOL !== $tokens[2]->id + ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[3]->id + || WP_MySQL_Lexer::IDENTIFIER !== $tokens[4]->id + || 'sql_mode' !== strtolower( $tokens[4]->get_value() ) + || ! $this->is_at_mysql_query_end( $tokens, 5 ) + ) { + return null; + } + + return '@@' . $tokens[2]->get_value() . '.' . $tokens[4]->get_value(); + } + + /** + * Check whether a query asks for MySQL FOUND_ROWS(). + * + * @param string $query MySQL query. + * @return bool Whether the query is SELECT FOUND_ROWS(). + */ + private function is_found_rows_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::IDENTIFIER !== $tokens[1]->id + || 'found_rows' !== strtolower( $tokens[1]->get_value() ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[3]->id + ) { + return false; + } + + return $this->is_at_mysql_query_end( $tokens, 4 ); + } + /** * Check whether a MySQL runtime setting is intentionally ignored. * 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 index dd390abe6..0d1453aec 100644 --- 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 @@ -113,6 +113,86 @@ public function test_translate_temporary_if_not_exists(): void { ); } + /** + * Tests MySQL charset metadata is extracted from CREATE TABLE statements. + */ + public function test_extract_schema_metadata_preserves_mysql_charsets(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + array( + 'table_name' => 'wp_charset_test', + 'columns' => array( + array( + 'name' => 'a', + 'type' => 'varchar(50)', + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + 'ordinal' => 1, + ), + array( + 'name' => 'b', + 'type' => 'text', + 'charset' => 'koi8r', + 'collation' => 'koi8r_general_ci', + 'ordinal' => 2, + ), + array( + 'name' => 'c', + 'type' => 'binary', + 'charset' => null, + 'collation' => null, + 'ordinal' => 3, + ), + array( + 'name' => 'd', + 'type' => 'int', + 'charset' => null, + 'collation' => null, + 'ordinal' => 4, + ), + ), + ), + ), + $translator->extract_schema_metadata( + 'CREATE TABLE wp_charset_test ( + a VARCHAR(50) CHARACTER SET latin1, + b TEXT COLLATE koi8r_general_ci, + c BINARY, + d INT + ) DEFAULT CHARSET utf8mb3' + ) + ); + } + + /** + * Tests numeric precision and scale are preserved in DDL and metadata. + */ + public function test_numeric_precision_and_scale_are_preserved(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_numeric_test ( + amount DECIMAL(10,2) NOT NULL, + ratio NUMERIC(12,6), + score FLOAT(10,3), + measure DOUBLE(8,4) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_numeric_test\" (\n \"amount\" numeric(10,2) NOT NULL,\n \"ratio\" numeric(12,6),\n \"score\" numeric(10,3),\n \"measure\" numeric(8,4)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql ); + + $this->assertSame( 'decimal(10,2)', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'numeric(12,6)', $metadata[0]['columns'][1]['type'] ); + $this->assertSame( 'float(10,3)', $metadata[0]['columns'][2]['type'] ); + $this->assertSame( 'double(8,4)', $metadata[0]['columns'][3]['type'] ); + } + /** * Tests unsupported CREATE TABLE ... SELECT statements are rejected. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 7a1fb1ce1..9bc4f895e 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -64,6 +64,260 @@ class wpdb {} ); } + /** + * Tests the wpdb adapter applies set_charset() to the PostgreSQL driver state. + */ + public function test_set_charset_updates_postgresql_driver_session_state(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $collate = ''; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->set_charset( $driver, 'utf8', 'utf8_general_ci' ); + +$collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); +$charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + +wp_postgresql_db_test_respond( + array( + 'charset' => $charset[0]->Value, + 'collation' => $collation[0]->Value, + ) +); +PHP + ); + + $this->assertSame( + array( + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + ), + $result + ); + } + + /** + * Tests the PostgreSQL adapter strips legacy charset text without MySQL. + */ + public function test_strip_invalid_text_handles_legacy_charsets_in_php(): void { + if ( ! function_exists( 'mb_convert_encoding' ) ) { + $this->markTestSkipped( 'mbstring is required for legacy charset conversion.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} +function __( $text ) { + return $text; +} + +class WP_Error {} + +class wpdb { + public $charset = 'big5'; + public $collate = ''; + + public function check_ascii( $text ) { + return 1 === preg_match( '/^[\x00-\x7F]*$/', $text ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'strip_invalid_text' ); +$method->setAccessible( true ); + +$utf8 = "a\xe5\x85\xb1b"; +$big5 = mb_convert_encoding( $utf8, 'BIG-5', 'UTF-8' ); + +$big5_result = $method->invoke( + $db, + array( + array( + 'charset' => 'big5', + 'value' => str_repeat( $big5, 10 ), + 'length' => array( + 'type' => 'byte', + 'length' => 10, + ), + ), + ) +); + +$db->charset = 'tis620'; +$tis620_result = $method->invoke( + $db, + array( + array( + 'charset' => 'tis620', + 'value' => str_repeat( "\xcc\xe3", 10 ), + 'length' => array( + 'type' => 'char', + 'length' => 10, + ), + ), + ) +); + +wp_postgresql_db_test_respond( + array( + 'big5' => bin2hex( $big5_result[0]['value'] ), + 'tis620' => bin2hex( $tis620_result[0]['value'] ), + ) +); +PHP + ); + + $big5 = mb_convert_encoding( "a\xe5\x85\xb1b", 'BIG-5', 'UTF-8' ); + + $this->assertSame( + array( + 'big5' => bin2hex( str_repeat( $big5, 2 ) . 'a' ), + 'tis620' => bin2hex( str_repeat( "\xcc\xe3", 5 ) ), + ), + $result + ); + } + + /** + * Tests query validation lets charset-aware stripping handle non-UTF-8 SQL. + */ + public function test_query_uses_strip_invalid_text_for_non_utf8_sql(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function __( $text ) { + return $text; +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( $hook_name, $value ) { + return $value; + } +} + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + public $check_current_query = true; + public $strip_calls = array(); + + public function check_ascii( $text ) { + return 1 === preg_match( '/^[\x00-\x7F]*$/', $text ); + } + + public function strip_invalid_text_from_query( $query ) { + $this->strip_calls[] = $query; + return $query; + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Invalid_Text_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return 1; + } + + public function get_last_return_value() { + return 1; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Invalid_Text_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$query = "INSERT INTO binary_probe (payload) VALUES ('\xff')"; +$return = $db->query( $query ); + +$queries = $driver->get_recorded_queries(); +wp_postgresql_db_test_respond( + array( + 'return' => $return, + 'strip_calls' => count( $db->strip_calls ), + 'strip_query_hex' => bin2hex( $db->strip_calls[0] ?? '' ), + 'driver_query_hex' => bin2hex( $queries[0] ?? '' ), + 'last_error' => $db->last_error, + 'check_current_query' => $db->check_current_query, + ) +); +PHP + ); + + $query = "INSERT INTO binary_probe (payload) VALUES ('\xff')"; + $this->assertSame( + array( + 'return' => 1, + 'strip_calls' => 1, + 'strip_query_hex' => bin2hex( $query ), + 'driver_query_hex' => bin2hex( $query ), + 'last_error' => '', + 'check_current_query' => true, + ), + $result + ); + } + /** * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. */ @@ -141,10 +395,10 @@ public function get_connection(): WP_PostgreSQL_Connection { $this->assertSame( '"wptests_options"', $result['quoted_table'] ); $this->assertSame( '"weird""name"', $result['quoted_weird'] ); $this->assertSame( - 'SELECT * FROM "wptests_options" WHERE "option_name" = \'Bob\'\'s\'', + 'SELECT * FROM `wptests_options` WHERE `option_name` = \'Bob\\\'s\'', $result['prepared_identifier'] ); - $this->assertSame( "SELECT 'Bob''s'", $result['prepared_string'] ); + $this->assertSame( "SELECT 'Bob\\'s'", $result['prepared_string'] ); } /** @@ -258,7 +512,7 @@ public function getAttribute( $attribute ): mixed { array( 'connect_result' => true, 'ready_after_connect' => true, - 'is_mysql' => false, + 'is_mysql' => true, 'last_error' => '', 'charset' => 'utf8mb4', 'bail_calls' => array(), @@ -273,7 +527,8 @@ public function getAttribute( $attribute ): mixed { 'driver_after_close' => true, 'second_close_result' => false, ), - $result + $result, + 'The PostgreSQL wpdb adapter keeps is_mysql=true so WordPress runs charset and length validation paths.' ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 6b3caea1e..888c80494 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -75,6 +75,116 @@ public function test_simple_wordpress_insert_with_backticks_is_translated_to_pos $this->assertSame( 'admin', $rows[0]->user_login ); } + /** + * Tests simple WordPress REPLACE statements update through PostgreSQL upserts. + */ + public function test_simple_wordpress_replace_with_existing_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", display_name) VALUES (2, \'Walter Sobchak\')' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Walter Replace Sobchak')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( $replace, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_users WHERE "ID" = 2' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); + } + + /** + * Tests simple REPLACE without a known conflict column falls back to INSERT. + */ + public function test_simple_wordpress_replace_without_known_conflict_column_is_inserted(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (post_name TEXT NOT NULL, post_status TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-world', 'publish')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-world\', \'publish\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL temporary table cleanup drops are translated for PostgreSQL. + */ + public function test_drop_temporary_table_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_cleanup (value TEXT)' ); + + $this->assertSame( 0, $driver->query( 'DROP TEMPORARY TABLE wptests_temp_cleanup' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE "wptests_temp_cleanup"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests temporary table creation with MySQL CHARACTER SET syntax is translated. + */ + public function test_create_temporary_table_with_character_set_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + $query = 'CREATE TEMPORARY TABLE wptests_charset_temp ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET big5 )'; + + $this->assertSame( 0, $driver->query( $query ) ); + $this->assertSame( + array( + array( + 'sql' => "CREATE TEMPORARY TABLE \"wptests_charset_temp\" (\n \"a\" varchar(50),\n \"b\" text\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests plain CHAR columns do not route through the MySQL DDL translator. + */ + public function test_create_table_with_plain_char_and_check_preserves_constraint(): void { + $driver = $this->create_driver(); + $query = 'CREATE TABLE plain_char_check (a CHAR(10) CHECK (length(a) > 0))'; + + $this->assertSame( 0, $driver->query( $query ) ); + $this->assertSame( + array( + array( + 'sql' => $query, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->expectException( PDOException::class ); + $driver->query( "INSERT INTO plain_char_check (a) VALUES ('')" ); + } + /** * Tests backticked SELECT identifiers are translated to PostgreSQL quoting. */ @@ -448,6 +558,51 @@ public function test_simple_delete_with_bare_uppercase_id_where_is_translated_to ); } + /** + * Tests WordPress expired transient cleanup DELETE statements are translated. + */ + public function test_wordpress_expired_transients_delete_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_expired', 'value')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_timeout_expired', '100')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_fresh', 'value')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_timeout_fresh', '9999999999')" ); + + $delete = "DELETE a, b FROM wptests_options a, wptests_options b + WHERE a.option_name LIKE '\\_transient\\_%' + AND a.option_name NOT LIKE '\\_transient\\_timeout\\_%' + AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) ) + AND b.option_value < 200"; + + $driver->query( $delete ); + + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WITH expired_transients AS', $queries[0]['sql'] ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options"', $queries[0]['sql'] ); + $this->assertStringContainsString( "SUBSTR(a.option_name, 12)", $queries[0]['sql'] ); + + $rows = $driver->query( 'SELECT option_name FROM wptests_options ORDER BY option_name' ); + + $this->assertSame( + array( '_transient_fresh', '_transient_timeout_fresh' ), + array_map( + function ( $row ) { + return $row->option_name; + }, + $rows + ) + ); + } + /** * Tests multi-assignment WordPress UPDATE statements are translated to PostgreSQL. */ @@ -483,6 +638,244 @@ public function test_multi_assignment_wordpress_update_with_backticks_is_transla $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 SELECT DISTINCT term ID queries include ORDER BY expressions. + */ + public function test_distinct_term_id_order_by_name_includes_order_expression_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( '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, 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"; + $rows = $driver->query( $select ); + + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT DISTINCT t.term_id, t.name 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', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * 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" 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 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests FOUND_ROWS returns the last SQL_CALC_FOUND_ROWS result 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( + "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, 2" + ); + $rows = $driver->query( 'SELECT FOUND_ROWS()' ); + + $this->assertSame( '2', $rows[0]->{'FOUND_ROWS()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + /** * Tests unsupported ON DUPLICATE KEY INSERT shapes still reach PDO. */ @@ -568,23 +961,319 @@ public function test_desc_existing_table_returns_mysql_shaped_column_metadata(): } /** - * Tests SHOW TABLES returns MySQL-shaped catalog rows. + * Tests SHOW FULL COLUMNS returns MySQL-shaped PostgreSQL catalog rows. */ - public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { + public function test_show_full_columns_returns_mysql_shaped_catalog_rows(): void { $driver = $this->create_driver(); $this->install_information_schema_fixture( $driver ); - $tables = $driver->query( "SHOW TABLES LIKE 'wptests_%'" ); + $result = $driver->query( 'SHOW FULL COLUMNS FROM `wptests_options`' ); - $this->assertCount( 3, $tables ); - $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); - $this->assertSame( 'wptests_posts', $tables[1]->Tables_in_wptests ); - $this->assertSame( 'wptests_view', $tables[2]->Tables_in_wptests ); - $this->assertSame( "SHOW TABLES LIKE 'wptests_%'", $driver->get_last_mysql_query() ); - $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + $this->assertCount( 4, $result ); + $this->assertSame( 'SHOW FULL COLUMNS FROM `wptests_options`', $driver->get_last_mysql_query() ); + $this->assertSame( 9, $driver->get_last_column_count() ); + $this->assertSame( 'Field', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Collation', $driver->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Comment', $driver->get_last_column_meta()[8]['name'] ); - $queries = $driver->get_last_postgresql_queries(); - $this->assertCount( 1, $queries ); + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'bigint', $result[0]->Type ); + $this->assertNull( $result[0]->Collation ); + $this->assertSame( 'PRI', $result[0]->Key ); + $this->assertSame( 'auto_increment', $result[0]->Extra ); + $this->assertSame( 'select,insert,update,references', $result[0]->Privileges ); + $this->assertSame( '', $result[0]->Comment ); + + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'varchar(191)', $result[1]->Type ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[1]->Collation ); + $this->assertSame( 'UNI', $result[1]->Key ); + + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 'text', $result[2]->Type ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[2]->Collation ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FULL COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS accepts MySQL table qualification forms. + */ + public function test_show_columns_accepts_table_qualification_forms(): void { + $cases = array( + 'SHOW COLUMNS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM public.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS IN wptests_options IN public' => array( 'public', 'wptests_options' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'], $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + + /** + * Tests SHOW COLUMNS LIKE filters catalog rows with bound parameters. + */ + public function test_show_columns_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options LIKE 'option_%'" ); + + $this->assertCount( 3, $result ); + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS uses the same MySQL metadata rows as DESCRIBE. + */ + public function test_show_columns_uses_mysql_schema_metadata_like_describe(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_meta_columns ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + lookup_key varchar(191) CHARACTER SET latin1 NOT NULL DEFAULT '', + payload longtext COLLATE koi8r_general_ci NOT NULL, + PRIMARY KEY (id), + KEY lookup_key (lookup_key) + )" + ); + + $describe = $driver->query( 'DESC wptests_meta_columns' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_meta_columns' ); + + $this->assertEquals( $describe, $show ); + $this->assertSame( 'id', $show[0]->Field ); + $this->assertSame( 'PRI', $show[0]->Key ); + $this->assertSame( 'auto_increment', $show[0]->Extra ); + $this->assertSame( 'lookup_key', $show[1]->Field ); + $this->assertSame( 'varchar(191)', $show[1]->Type ); + $this->assertSame( 'MUL', $show[1]->Key ); + $this->assertSame( 'payload', $show[2]->Field ); + $this->assertSame( 'longtext', $show[2]->Type ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( '__wp_postgresql_mysql_column_metadata', $queries[0]['sql'] ); + $this->assertStringContainsString( '__wp_postgresql_mysql_index_metadata', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_meta_columns' ), $queries[0]['params'] ); + + $filtered = $driver->query( "SHOW COLUMNS FROM wptests_meta_columns LIKE 'lookup%'" ); + + $this->assertCount( 1, $filtered ); + $this->assertSame( 'lookup_key', $filtered[0]->Field ); + $this->assertSame( 'MUL', $filtered[0]->Key ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_meta_columns', 'lookup%' ), $queries[0]['params'] ); + + $full = $driver->query( 'SHOW FULL COLUMNS FROM wptests_meta_columns' ); + + $this->assertSame( 9, $driver->get_last_column_count() ); + $this->assertSame( 'id', $full[0]->Field ); + $this->assertNull( $full[0]->Collation ); + $this->assertSame( 'lookup_key', $full[1]->Field ); + $this->assertSame( 'latin1_swedish_ci', $full[1]->Collation ); + $this->assertSame( 'MUL', $full[1]->Key ); + $this->assertSame( 'payload', $full[2]->Field ); + $this->assertSame( 'koi8r_general_ci', $full[2]->Collation ); + } + + /** + * Tests DESCRIBE and SHOW COLUMNS preserve numeric precision and scale metadata. + */ + public function test_describe_preserves_numeric_precision_and_scale_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_numeric_meta ( + amount DECIMAL(10,2) NOT NULL, + ratio NUMERIC(12,6), + score FLOAT(10,3), + measure DOUBLE(8,4) + )' + ); + + $describe = $driver->query( 'DESC wptests_numeric_meta' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_numeric_meta' ); + + $this->assertSame( 'decimal(10,2)', $describe[0]->Type ); + $this->assertSame( 'numeric(12,6)', $describe[1]->Type ); + $this->assertSame( 'float(10,3)', $describe[2]->Type ); + $this->assertSame( 'double(8,4)', $describe[3]->Type ); + $this->assertEquals( $describe, $show ); + } + + /** + * Tests CHANGE COLUMN without DEFAULT removes the backend and metadata defaults. + */ + public function test_change_column_without_default_drops_existing_default(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_defaults', 'BASE TABLE')" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_defaults', 'post_title', 1, 'character varying', 20, 'utf8mb4_unicode_ci', 'NO', '''stale''::character varying', 'NO')" + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_defaults ( + post_title varchar(20) NOT NULL DEFAULT 'stale' + )" + ); + + $driver->query( 'ALTER TABLE wptests_defaults CHANGE COLUMN post_title post_title text NOT NULL' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" DROP DEFAULT', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_defaults' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_defaults' ); + + $this->assertSame( 'post_title', $describe[0]->Field ); + $this->assertSame( 'text', $describe[0]->Type ); + $this->assertNull( $describe[0]->Default ); + $this->assertNull( $show[0]->Default ); + } + + /** + * Tests CHANGE COLUMN preserves existing identity DDL while updating MySQL metadata. + */ + public function test_change_column_auto_increment_integer_family_preserves_identity_ddl_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_identity', 'BASE TABLE')" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_identity', 'id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES')" + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity ( + id bigint(20) NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) + )' + ); + + $driver->query( 'ALTER TABLE wptests_identity CHANGE COLUMN `id` id int(11) NOT NULL AUTO_INCREMENT' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_identity" ALTER COLUMN "id" SET NOT NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_identity' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_identity' ); + + $this->assertEquals( $describe, $show ); + $this->assertSame( 'id', $show[0]->Field ); + $this->assertSame( 'int(11)', $show[0]->Type ); + $this->assertSame( 'NO', $show[0]->Null ); + $this->assertNull( $show[0]->Default ); + $this->assertSame( 'auto_increment', $show[0]->Extra ); + } + + /** + * Tests unsupported SHOW COLUMNS clauses do not fall through to the backend. + */ + public function test_show_columns_where_clause_does_not_reach_backend(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); + $this->fail( 'Expected unsupported SHOW COLUMNS WHERE clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests SHOW TABLES returns MySQL-shaped catalog rows. + */ + public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW TABLES LIKE 'wptests_%'" ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); + $this->assertSame( 'wptests_posts', $tables[1]->Tables_in_wptests ); + $this->assertSame( 'wptests_view', $tables[2]->Tables_in_wptests ); + $this->assertSame( "SHOW TABLES LIKE 'wptests_%'", $driver->get_last_mysql_query() ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); $this->assertStringContainsString( 'information_schema.tables', $queries[0]['sql'] ); $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'] ); $this->assertSame( array( 'public', 'wptests_%' ), $queries[0]['params'] ); @@ -600,6 +1289,124 @@ public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { $this->assertSame( 'Table_type', $driver->get_last_column_meta()[1]['name'] ); } + /** + * Tests SHOW TABLES hides internal PostgreSQL metadata tables. + */ + public function test_show_tables_hides_internal_postgresql_metadata_tables(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', '__wp_postgresql_mysql_column_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_index_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_charset_metadata', 'BASE TABLE')" + ); + + $raw_catalog_tables = $driver->get_connection()->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + '__wp_postgresql_mysql_column_metadata', + '__wp_postgresql_mysql_index_metadata', + '__wp_postgresql_mysql_charset_metadata' + ) + ORDER BY table_name" + )->fetchAll( PDO::FETCH_COLUMN ); + + $this->assertSame( + array( + '__wp_postgresql_mysql_charset_metadata', + '__wp_postgresql_mysql_column_metadata', + '__wp_postgresql_mysql_index_metadata', + ), + $raw_catalog_tables + ); + + $tables = $driver->query( 'SHOW TABLES' ); + $names = array_map( + function ( $row ) { + return $row->Tables_in_wptests; + }, + $tables + ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts', 'wptests_view' ), + $names + ); + } + + /** + * Tests SHOW INDEX returns MySQL-shaped PostgreSQL catalog rows. + */ + public function test_show_index_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + + $this->assertCount( 3, $indexes ); + $this->assertSame( 'SHOW INDEX FROM `wptests_options`;', $driver->get_last_mysql_query() ); + $this->assertSame( 15, $driver->get_last_column_count() ); + $this->assertSame( 'Table', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Key_name', $driver->get_last_column_meta()[2]['name'] ); + + $this->assertCount( 15, get_object_vars( $indexes[0] ) ); + $this->assertSame( 'wptests_options', $indexes[0]->Table ); + $this->assertSame( '0', $indexes[0]->Non_unique ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( '1', $indexes[0]->Seq_in_index ); + $this->assertSame( 'option_id', $indexes[0]->Column_name ); + $this->assertSame( 'A', $indexes[0]->Collation ); + $this->assertSame( '0', $indexes[0]->Cardinality ); + $this->assertNull( $indexes[0]->Sub_part ); + $this->assertNull( $indexes[0]->Packed ); + $this->assertSame( '', $indexes[0]->Null ); + $this->assertSame( 'BTREE', $indexes[0]->Index_type ); + $this->assertSame( '', $indexes[0]->Comment ); + $this->assertSame( '', $indexes[0]->Index_comment ); + $this->assertSame( 'YES', $indexes[0]->Visible ); + $this->assertNull( $indexes[0]->Expression ); + + $this->assertSame( 'option_name', $indexes[1]->Key_name ); + $this->assertSame( 'option_name', $indexes[1]->Column_name ); + $this->assertSame( '0', $indexes[1]->Non_unique ); + $this->assertSame( 'autoload', $indexes[2]->Key_name ); + $this->assertSame( 'autoload', $indexes[2]->Column_name ); + $this->assertSame( '1', $indexes[2]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'] ); + $this->assertStringContainsString( 'pg_catalog.unnest(i.indkey)', $queries[0]['sql'] ); + $this->assertStringContainsString( 'show_index_rows', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEXES WHERE Key_name filters on normalized MySQL index names. + */ + public function test_show_indexes_where_key_name_filters_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW INDEXES FROM wptests_options WHERE Key_name = 'autoload'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEXES', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); + } + /** * Tests MySQL-only runtime SET statements are ignored before reaching PDO. */ @@ -629,6 +1436,86 @@ public function test_mysql_runtime_set_statements_are_noops(): void { } } + /** + * 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 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 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 are still sent to PDO. */ @@ -661,6 +1548,16 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Creates a PostgreSQL driver with SHOW INDEX fixture rows. + * + * @return WP_PostgreSQL_Driver + */ + private function create_show_index_driver(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Driver_Show_Index_Fixture_Connection(); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + /** * Install a small information_schema fixture into the injected PDO. * @@ -685,6 +1582,7 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive ordinal_position INTEGER NOT NULL, data_type TEXT NOT NULL, character_maximum_length INTEGER, + collation_name TEXT, is_nullable TEXT NOT NULL, column_default TEXT, is_identity TEXT NOT NULL @@ -720,12 +1618,12 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive ); $pdo->exec( "INSERT INTO information_schema.columns - (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, is_nullable, column_default, is_identity) + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) VALUES - ('public', 'wptests_options', 'option_id', 1, 'bigint', NULL, 'NO', NULL, 'YES'), - ('public', 'wptests_options', 'option_name', 2, 'character varying', 191, 'NO', NULL, 'NO'), - ('public', 'wptests_options', 'option_value', 3, 'text', NULL, 'NO', NULL, 'NO'), - ('public', 'wptests_options', 'autoload', 4, 'character varying', 20, 'NO', '''yes''::character varying', 'NO')" + ('public', 'wptests_options', 'option_id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES'), + ('public', 'wptests_options', 'option_name', 2, 'character varying', 191, 'utf8mb4_unicode_ci', 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'option_value', 3, 'text', NULL, 'utf8mb4_unicode_ci', 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'autoload', 4, 'character varying', 20, 'utf8mb4_unicode_ci', 'NO', '''yes''::character varying', 'NO')" ); $pdo->exec( "INSERT INTO information_schema.table_constraints @@ -743,3 +1641,127 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive ); } } + +/** + * Fixture connection that accepts PostgreSQL ALTER TABLE syntax in driver tests. + */ +class WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection extends WP_PostgreSQL_Connection { + /** + * Constructor. + */ + public function __construct() { + parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + } + + /** + * Execute a query, accepting PostgreSQL ALTER TABLE statements as no-ops. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( 0 === strpos( $sql, 'ALTER TABLE ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + return parent::query( $sql, $params ); + } +} + +/** + * Fixture connection for PostgreSQL SHOW INDEX catalog tests. + */ +class WP_PostgreSQL_Driver_Show_Index_Fixture_Connection extends WP_PostgreSQL_Connection { + /** + * Constructor. + */ + public function __construct() { + parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + + $this->install_fixture(); + } + + /** + * Execute a query against the fixture when the PostgreSQL catalog query is used. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false === strpos( $sql, 'pg_catalog.pg_index' ) ) { + return parent::query( $sql, $params ); + } + + $fixture_sql = 'SELECT + table_name AS "Table", + non_unique AS "Non_unique", + key_name AS "Key_name", + seq_in_index AS "Seq_in_index", + column_name AS "Column_name", + collation AS "Collation", + cardinality AS "Cardinality", + sub_part AS "Sub_part", + packed AS "Packed", + nullable AS "Null", + index_type AS "Index_type", + comment AS "Comment", + index_comment AS "Index_comment", + visible AS "Visible", + expression AS "Expression" + FROM show_index_fixture + WHERE table_schema = ? + AND table_name = ?'; + $fixture_params = array( $params[0] ?? '', $params[1] ?? '' ); + + if ( isset( $params[2] ) ) { + $fixture_sql .= ' + AND key_name = ?'; + $fixture_params[] = $params[2]; + } + + $fixture_sql .= ' + ORDER BY sort_position, CAST(seq_in_index AS INTEGER)'; + + return parent::query( $fixture_sql, $fixture_params ); + } + + /** + * Install SHOW INDEX fixture rows into the injected PDO. + */ + private function install_fixture(): void { + $pdo = $this->get_pdo(); + + $pdo->exec( + 'CREATE TABLE show_index_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + sort_position INTEGER NOT NULL, + non_unique TEXT NOT NULL, + key_name TEXT NOT NULL, + seq_in_index TEXT NOT NULL, + column_name TEXT, + collation TEXT, + cardinality TEXT, + sub_part TEXT, + packed TEXT, + nullable TEXT NOT NULL, + index_type TEXT NOT NULL, + comment TEXT NOT NULL, + index_comment TEXT NOT NULL, + visible TEXT NOT NULL, + expression TEXT + )' + ); + $pdo->exec( + "INSERT INTO show_index_fixture + (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) + VALUES + ('public', 'wptests_options', 1, '0', 'PRIMARY', '1', 'option_id', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 2, '0', 'option_name', '1', 'option_name', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 3, '1', 'autoload', '1', 'autoload', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_posts', 4, '0', 'PRIMARY', '1', 'ID', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL)" + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php index 8edc37873..c8029ae69 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php @@ -95,6 +95,14 @@ 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' ); } @@ -103,8 +111,8 @@ function populate_roles() { wp_pg_install_test_event( 'populate_roles' ); } -function update_option( $option, $value ) { - wp_pg_install_test_event( array( 'update_option', $option, $value ) ); +function update_option( $option, $value, $autoload = null ) { + wp_pg_install_test_event( array( 'update_option', $option, $value, $autoload ) ); return true; } @@ -192,6 +200,7 @@ function wp_get_db_schema() { ); define( 'ABSPATH', $root ); +define( 'HOUR_IN_SECONDS', 3600 ); $GLOBALS['wp_pg_install_test_events'] = array(); $GLOBALS['wpdb'] = new class() { public $last_error = ''; @@ -226,15 +235,92 @@ public function query( $statement ) { '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, 4 ) + array_slice( $result['events'], 2, 10 ) + ); + $this->assertContains( + array( 'update_option', 'fresh_site', 1, false ), + $result['events'] ); $this->assertSame( 42, $result['result']['user_id'] ); $this->assertSame( 'secret-password', $result['result']['password'] ); } + /** + * Tests network installation creates the global schema through the translator. + */ + public function test_install_network_creates_postgresql_global_schema(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'queries[] = $statement; + return true; + } +}; + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + +install_network(); + +$installing_network = defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK; + +wp_pg_install_test_remove_tree( $root ); +wp_pg_install_test_respond( + array( + 'installing_network' => $installing_network, + 'queries' => $GLOBALS['wpdb']->queries, + ) +); +PHP + ); + + $this->assertTrue( $result['installing_network'] ); + $this->assertSame( + array( + "CREATE TABLE \"wp_site\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"domain\" varchar(200) NOT NULL DEFAULT '',\n \"path\" varchar(100) NOT NULL DEFAULT '',\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_site__domain" ON "wp_site" ("domain", "path")', + ), + $result['queries'] + ); + } + /** * Runs an install-functions script in a separate PHP process. * diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index b5dee816c..c7fdc4ff3 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -7,6 +7,7 @@ if ( ! class_exists( 'WP_PostgreSQL_Driver', false ) ) { require_once __DIR__ . '/../database/postgresql/class-wp-postgresql-connection.php'; + require_once __DIR__ . '/../database/postgresql/class-wp-postgresql-create-table-translator.php'; require_once __DIR__ . '/../database/postgresql/class-wp-postgresql-driver.php'; } @@ -23,6 +24,8 @@ * of reusing the SQLite file-backed connection path. */ class WP_PostgreSQL_DB extends wpdb { + const MYSQL_CHARSET_METADATA_TABLE = '__wp_postgresql_mysql_charset_metadata'; + /** * Database handle. * @@ -49,7 +52,9 @@ public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { $GLOBALS['wpdb'] = $this; parent::__construct( $dbuser, $dbpassword, $dbname, $dbhost ); - $this->charset = 'utf8mb4'; + if ( ! $this->charset ) { + $this->charset = 'utf8mb4'; + } } /** @@ -59,7 +64,22 @@ public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { * @param string $charset Optional. The character set. * @param string $collate Optional. The collation. */ - public function set_charset( $dbh, $charset = null, $collate = null ) {} + 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. @@ -69,18 +89,937 @@ public function set_charset( $dbh, $charset = null, $collate = null ) {} * @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 = trim( (string) $table, "`\" \t\n\r\0\x0B" ); + $column = trim( (string) $column, "`\" \t\n\r\0\x0B" ); + if ( false !== strpos( $table, '.' ) ) { + $table = substr( $table, strrpos( $table, '.' ) + 1 ); + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + } + + 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 ) ) { + 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 ) { + return array( + 'type' => 'char', + 'length' => $length, + ); + } + + if ( 'text' === $type ) { + return array( + 'type' => 'byte', + 'length' => 65535, + ); + } + + return false; + } + + /** + * Determines the best charset/collation pair for PostgreSQL-backed wpdb. + * + * Core returns early for non-mysqli handles. The PostgreSQL backend still + * advertises MySQL-compatible charset capabilities to WordPress, so apply + * the same utf8-to-utf8mb4 upgrade rules without the mysqli guard. + * + * @param string $charset Requested charset. + * @param string $collate Requested collation. + * @return array{charset: string, collate: string} Charset/collation pair. + */ + public function determine_charset( $charset, $collate ) { + if ( empty( $this->dbh ) ) { + return compact( 'charset', 'collate' ); + } + + if ( 'utf8' === $charset ) { + $charset = 'utf8mb4'; + } + + if ( 'utf8mb4' === $charset ) { + if ( ! $collate || 'utf8_general_ci' === $collate ) { + $collate = 'utf8mb4_unicode_ci'; + } else { + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate ); + } + } + + if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + + /** + * Strip invalid UTF-8 text using WordPress core's local regex path. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param int|false $length Optional character length. + * @return string Stripped value. + */ + private function strip_postgresql_invalid_utf8_text( string $value, string $charset, $length ): string { + $regex = '/ + ( + (?: [\x00-\x7F] + | [\xC2-\xDF][\x80-\xBF] + | \xE0[\xA0-\xBF][\x80-\xBF] + | [\xE1-\xEC][\x80-\xBF]{2} + | \xED[\x80-\x9F][\x80-\xBF] + | [\xEE-\xEF][\x80-\xBF]{2}'; + + if ( 'utf8mb4' === $charset ) { + $regex .= ' + | \xF0[\x90-\xBF][\x80-\xBF]{2} + | [\xF1-\xF3][\x80-\xBF]{3} + | \xF4[\x80-\x8F][\x80-\xBF]{2} + '; + } + + $regex .= '){1,40} + ) + | . + /x'; + + $value = preg_replace( $regex, '$1', $value ); + if ( false !== $length && mb_strlen( $value, 'UTF-8' ) > $length ) { + $value = mb_substr( $value, 0, $length, 'UTF-8' ); + } + + return $value; + } + + /** + * Strip invalid text for MySQL legacy charsets using PHP conversion. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param array|false $length Optional length metadata. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_legacy_text( string $value, string $charset, $length ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + $connection_charset = $this->get_postgresql_connection_charset(); + + if ( is_array( $length ) && 'byte' === $length['type'] ) { + return $this->strip_postgresql_invalid_trailing_bytes( $value, $connection_charset ); + } + + if ( $charset === $connection_charset && $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + if ( is_array( $length ) ) { + return substr( $value, 0, (int) $length['length'] ); + } + + return $value; + } + + if ( ! function_exists( 'mb_convert_encoding' ) ) { + return false; + } + + $target_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + $connection_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $connection_charset ); + if ( null === $target_encoding || null === $connection_encoding ) { + return false; + } + + $target_value = $value; + if ( $target_encoding !== $connection_encoding ) { + $target_value = mb_convert_encoding( $value, $target_encoding, $connection_encoding ); + } + + if ( is_array( $length ) ) { + $target_value = mb_substr( $target_value, 0, (int) $length['length'], $target_encoding ); + } + + if ( $target_encoding === $connection_encoding ) { + return $this->strip_postgresql_invalid_trailing_bytes( $target_value, $connection_charset ); + } + + return mb_convert_encoding( $target_value, $connection_encoding, $target_encoding ); + } + + /** + * Strip a partial trailing multibyte sequence after byte truncation. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_trailing_bytes( string $value, string $charset ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + if ( $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + return $value; + } + + $encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + if ( null === $encoding || ! function_exists( 'mb_check_encoding' ) ) { + return false; + } + + while ( '' !== $value && ! mb_check_encoding( $value, $encoding ) ) { + $value = substr( $value, 0, -1 ); + } + + return $value; + } + + /** + * Get the current MySQL-compatible connection charset. + * + * @return string Charset. + */ + private function get_postgresql_connection_charset(): string { + if ( ! empty( $this->charset ) ) { + return $this->normalize_postgresql_mysql_charset( (string) $this->charset ); + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + return $this->normalize_postgresql_mysql_charset( $this->dbh->get_charset() ); + } + return 'utf8mb4'; } + /** + * Normalize a MySQL charset for PostgreSQL adapter logic. + * + * @param string $charset Charset. + * @return string Normalized charset. + */ + private function normalize_postgresql_mysql_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Check whether a charset is single-byte for truncation purposes. + * + * @param string $charset MySQL charset. + * @return bool Whether the charset is single-byte. + */ + private function is_postgresql_single_byte_mysql_charset( string $charset ): bool { + return in_array( + $this->normalize_postgresql_mysql_charset( $charset ), + array( 'ascii', 'binary', 'cp1251', 'hebrew', 'koi8r', 'latin1', 'tis620' ), + true + ); + } + + /** + * Map a MySQL charset to a PHP mbstring encoding. + * + * @param string $charset MySQL charset. + * @return string|null PHP encoding, or null when unsupported. + */ + private function get_postgresql_php_encoding_for_mysql_charset( string $charset ): ?string { + $encodings = array( + 'ascii' => 'ASCII', + 'big5' => 'BIG-5', + 'cp1251' => 'Windows-1251', + 'hebrew' => 'ISO-8859-8', + 'koi8r' => 'KOI8-R', + 'latin1' => 'ISO-8859-1', + 'ujis' => 'EUC-JP', + 'utf8' => 'UTF-8', + 'utf8mb4' => 'UTF-8', + ); + + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + return $encodings[ $charset ] ?? null; + } + + /** + * Store MySQL charset metadata for a successfully created PostgreSQL table. + * + * @param string $query Original MySQL CREATE TABLE query. + */ + private function store_postgresql_create_table_charset_metadata( string $query ): void { + if ( ! $this->has_usable_postgresql_connection() || ! class_exists( 'WP_PostgreSQL_Create_Table_Translator', false ) ) { + return; + } + + if ( ! $this->is_postgresql_mysql_charset_metadata_create_query( $query ) ) { + return; + } + + try { + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query ); + if ( empty( $metadata ) ) { + return; + } + + $this->ensure_postgresql_charset_metadata_table(); + + foreach ( $metadata as $table ) { + if ( empty( $table['table_name'] ) ) { + continue; + } + + $table_name = (string) $table['table_name']; + $this->dbh->get_connection()->query( + 'DELETE FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?)', + array( $table_name ) + ); + + foreach ( (array) ( $table['columns'] ?? array() ) as $column ) { + $this->dbh->get_connection()->query( + 'INSERT INTO ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' ( + table_schema, + table_name, + column_name, + ordinal_position, + column_type, + character_set_name, + collation_name + ) VALUES ( + current_schema(), + ?, + ?, + ?, + ?, + ?, + ? + )', + array( + $table_name, + (string) $column['name'], + (int) $column['ordinal'], + (string) $column['type'], + $column['charset'], + $column['collation'], + ) + ); + } + + $tablekey = $this->get_postgresql_metadata_key( $table_name ); + unset( $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ] ); + } + } 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; + } + + try { + if ( ! $this->postgresql_charset_metadata_table_exists() ) { + return; + } + + foreach ( $tables as $table ) { + $this->dbh->get_connection()->query( + 'DELETE FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?)', + array( $table ) + ); + + $tablekey = $this->get_postgresql_metadata_key( $table ); + unset( $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ] ); + } + } catch ( Throwable $e ) { + return; + } + } + + /** + * Ensure the PostgreSQL side table for MySQL charset metadata exists. + */ + private function ensure_postgresql_charset_metadata_table(): void { + $this->dbh->get_connection()->query( + 'CREATE TABLE IF NOT EXISTS ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' ( + table_schema text NOT NULL, + table_name text NOT NULL, + column_name text NOT NULL, + ordinal_position integer NOT NULL, + column_type text NOT NULL, + character_set_name text, + collation_name text, + PRIMARY KEY (table_schema, table_name, column_name) + )' + ); + } + + /** + * Check whether a CREATE TABLE query carries MySQL charset metadata. + * + * The WordPress PostgreSQL installer executes already-translated PostgreSQL + * DDL. That SQL must not be fed back into the MySQL parser used for metadata + * extraction. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query should be parsed as MySQL charset DDL. + */ + private function is_postgresql_mysql_charset_metadata_create_query( string $query ): bool { + return 1 === preg_match( '/\b(?:CHARSET|CHARACTER\s+SET|COLLATE|ASCII|UNICODE|BINARY)\b/i', $query ); + } + + /** + * Check whether the side table for MySQL charset metadata exists. + * + * @return bool Whether the metadata table exists in the current schema. + */ + private function postgresql_charset_metadata_table_exists(): bool { + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = ? + )', + array( self::MYSQL_CHARSET_METADATA_TABLE ) + ); + return (bool) $stmt->fetchColumn(); + } catch ( Throwable $e ) { + return false; + } + } + + /** + * 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; + } + + $columns = $this->get_stored_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && ! empty( $columns ) ) { + return $columns; + } + + return $this->get_native_postgresql_column_charset_metadata( $table ); + } + + /** + * Load previously stored MySQL charset metadata. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_stored_postgresql_column_charset_metadata( string $table ) { + if ( ! $this->postgresql_charset_metadata_table_exists() ) { + return false; + } + + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT column_name, column_type, collation_name + FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?) + ORDER BY ordinal_position', + array( $this->normalize_postgresql_table_name( $table ) ) + ); + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + return $this->format_postgresql_charset_column_rows( $rows ); + } + + /** + * 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 ) { + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT column_name, 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 lower(table_name) = lower(?) + ORDER BY CASE + WHEN table_schema = ( + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE oid = pg_my_temp_schema() + ) THEN 0 + ELSE 1 + END, + ordinal_position', + array( $this->normalize_postgresql_table_name( $table ) ) + ); + $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; + } + + /** + * Calculate WordPress's table charset value from column metadata. + * + * @param array $columns Column metadata. + * @return string|false Table charset, or false for tables without text columns. + */ + private function get_postgresql_table_charset_from_columns( array $columns ) { + $charsets = array(); + + foreach ( $columns as $column ) { + $collation = $column->{'Collation'}; + if ( ! empty( $collation ) ) { + list( $charset ) = explode( '_', $collation ); + + $charsets[ strtolower( $charset ) ] = true; + } + + $column_type = $column->{'Type'}; + + list( $type ) = explode( '(', $column_type ); + if ( in_array( strtoupper( $type ), array( 'BINARY', 'VARBINARY', 'TINYBLOB', 'MEDIUMBLOB', 'BLOB', 'LONGBLOB' ), true ) ) { + return 'binary'; + } + } + + if ( isset( $charsets['utf8mb3'] ) ) { + $charsets['utf8'] = true; + unset( $charsets['utf8mb3'] ); + } + + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 0 === $count ) { + return false; + } + + unset( $charsets['latin1'] ); + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 2 === $count && isset( $charsets['utf8'], $charsets['utf8mb4'] ) ) { + return 'utf8'; + } + + return 'ascii'; + } + + /** + * Convert PostgreSQL catalog types to MySQL-ish metadata types. + * + * @param string $type PostgreSQL data type. + * @param int $length Character length. + * @return string MySQL-ish column type. + */ + private function get_postgresql_native_mysql_column_type( string $type, int $length ): string { + if ( 'character varying' === $type ) { + return $length > 0 ? sprintf( 'varchar(%d)', $length ) : 'varchar'; + } + + if ( 'character' === $type ) { + return $length > 0 ? sprintf( 'char(%d)', $length ) : 'char'; + } + + if ( 'bytea' === $type ) { + return 'blob'; + } + + if ( 'integer' === $type ) { + return 'int'; + } + + if ( 'double precision' === $type || 'real' === $type || 'numeric' === $type ) { + return 'float'; + } + + return $type; + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset. + * @return string Collation. + */ + private function get_postgresql_default_collation_for_charset( string $charset ): string { + $charset = strtolower( $charset ); + if ( 'utf8mb3' === $charset ) { + $charset = 'utf8'; + } + + $collations = array( + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + 'latin1' => 'latin1_swedish_ci', + 'big5' => 'big5_chinese_ci', + 'koi8r' => 'koi8r_general_ci', + 'cp1251' => 'cp1251_general_ci', + 'ascii' => 'ascii_general_ci', + ); + + return $collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Get table names from a DROP TABLE query. + * + * @param string $query DROP TABLE query. + * @return string[] Table names. + */ + private function get_postgresql_drop_table_names( string $query ): array { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return array(); + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return array(); + } + + $tables = array(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $value = $token->get_value(); + if ( ! in_array( strtolower( $value ), array( 'drop', 'temporary', 'table', 'if', 'exists', 'restrict', 'cascade' ), true ) ) { + $tables[] = $value; + } + } + } + + return $tables; + } + + /** + * Normalize a table name for PostgreSQL metadata lookups. + * + * @param string $table Table identifier. + * @return string Table name. + */ + private function normalize_postgresql_table_name( string $table ): string { + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + if ( false !== strpos( $table, '.' ) ) { + $table = substr( $table, strrpos( $table, '.' ) + 1 ); + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + } + + return $table; + } + + /** + * Normalize an identifier for wpdb metadata cache keys. + * + * @param string $identifier Identifier. + * @return string Metadata key. + */ + private function get_postgresql_metadata_key( string $identifier ): string { + return strtolower( $this->normalize_postgresql_table_name( $identifier ) ); + } + + /** + * Checks whether the adapter has a usable PDO-backed PostgreSQL connection. + * + * @return bool Whether a real connection is available. + */ + private function has_usable_postgresql_connection(): bool { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + try { + return $this->dbh->get_connection()->get_pdo() instanceof PDO; + } catch ( Throwable $e ) { + return false; + } + } + /** * Changes the current SQL mode. * - * PostgreSQL does not expose MySQL sql_mode. The MySQL-emulation layer will - * own this state once query translation is implemented. + * 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() ) {} + public function set_sql_mode( $modes = array() ) { + if ( empty( $modes ) ) { + return; + } + + $modes = array_map( 'strtoupper', (array) $modes ); + + $incompatible_modes = property_exists( $this, 'incompatible_modes' ) ? (array) $this->incompatible_modes : array(); + if ( function_exists( 'apply_filters' ) ) { + $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $incompatible_modes ); + } + + foreach ( $modes as $i => $mode ) { + if ( in_array( $mode, $incompatible_modes, true ) ) { + unset( $modes[ $i ] ); + } + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $this->dbh->set_sql_mode( implode( ',', array_values( $modes ) ) ); + } + } /** * Closes the current database connection. @@ -125,13 +1064,6 @@ public function _real_escape( $data ) { } $escaped = addslashes( (string) $data ); - if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { - $quoted = $this->dbh->get_connection()->quote( (string) $data ); - if ( false !== $quoted && 2 <= strlen( $quoted ) && "'" === $quoted[0] && "'" === substr( $quoted, -1 ) ) { - $escaped = substr( $quoted, 1, -1 ); - } - } - return $this->add_placeholder_escape( $escaped ); } @@ -235,7 +1167,7 @@ public function flush() { * @return bool Whether the connection succeeded. */ public function db_connect( $allow_bail = true ) { - $this->is_mysql = false; + $this->is_mysql = true; if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { $this->ready = true; @@ -316,22 +1248,7 @@ public function prepare( $query, ...$args ) { 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; - } - - return strtr( $prepared, $identifier_prepare['identifiers'] ); + return parent::prepare( $query, ...$args ); } /** @@ -549,9 +1466,41 @@ public function query( $query ) { } $this->flush(); - $this->func_call = "\$db->query(\"$query\")"; + $this->func_call = "\$db->query(\"$query\")"; + + $check_current_query = true; + if ( property_exists( $this, 'check_current_query' ) ) { + $check_current_query = (bool) $this->check_current_query; + } + + if ( + $check_current_query + && is_string( $query ) + && method_exists( $this, 'check_ascii' ) + && method_exists( $this, 'strip_invalid_text_from_query' ) + && ! $this->check_ascii( $query ) + ) { + $stripped_query = $this->strip_invalid_text_from_query( $query ); + $this->flush(); + if ( $stripped_query !== $query ) { + $this->insert_id = 0; + $this->last_query = $query; + wp_load_translations_early(); + $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' ); + return false; + } + } + + if ( property_exists( $this, 'check_current_query' ) ) { + $this->check_current_query = true; + } $this->last_query = $query; + if ( is_string( $query ) && preg_match( '/^\s*UPDATE\s+.+\s+WHERE\s*$/is', $query ) ) { + $this->last_error = 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.'; + return false; + } + $last_query_count = count( $this->queries ?? array() ); $this->_do_query( $query ); @@ -565,6 +1514,12 @@ public function query( $query ) { } $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 ); + } + 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 ) ) { @@ -606,6 +1561,8 @@ public function has_cap( $db_cap ) { 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', '>=' ); 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 index bba334543..4490abe97 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -11,13 +11,14 @@ * * @return bool True when schema installation succeeds. */ - function postgresql_make_db_current_silent() { + function postgresql_make_db_current_silent( $tables = 'all' ) { global $wpdb; include_once ABSPATH . 'wp-admin/includes/schema.php'; $translator = new WP_PostgreSQL_Create_Table_Translator(); - $statements = $translator->translate_schema( wp_get_db_schema() ); + $schema = 'all' === $tables ? wp_get_db_schema() : wp_get_db_schema( $tables ); + $statements = $translator->translate_schema( $schema ); foreach ( $statements as $statement ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Generated from parsed WordPress schema DDL. @@ -31,10 +32,27 @@ function postgresql_make_db_current_silent() { } } + if ( $wpdb->dbh instanceof WP_PostgreSQL_Driver ) { + $wpdb->dbh->store_mysql_schema_metadata( $schema ); + } + return true; } } +if ( ! function_exists( 'install_network' ) ) { + /** + * Create WordPress multisite global tables for PostgreSQL. + */ + function install_network() { + if ( ! defined( 'WP_INSTALLING_NETWORK' ) ) { + define( 'WP_INSTALLING_NETWORK', true ); + } + + postgresql_make_db_current_silent( 'global' ); + } +} + if ( ! function_exists( 'wp_install' ) ) { /** * Installs the site. @@ -66,6 +84,21 @@ function wp_install( $blog_title, $user_name, $user_email, $is_public, $deprecat 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(); @@ -74,7 +107,7 @@ function wp_install( $blog_title, $user_name, $user_email, $is_public, $deprecat 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 ); + update_option( 'fresh_site', 1, false ); if ( $language ) { update_option( 'WPLANG', $language ); diff --git a/wp-setup.sh b/wp-setup.sh index de2d866be..77427dd42 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -11,7 +11,7 @@ set -e WP_VERSION="6.7.2" WP_TEST_DB_BACKEND="${WP_TEST_DB_BACKEND:-${1:-sqlite}}" -DIR="$(dirname "$0")" +DIR="$(cd "$(dirname "$0")" && pwd)" WP_DIR="$DIR/wordpress" case "$WP_TEST_DB_BACKEND" in @@ -50,10 +50,18 @@ 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 '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 + 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" @@ -105,7 +113,8 @@ FROM wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e3836 USER root RUN if command -v git > /dev/null; then \ - git config --global --add safe.directory /var/www; \ + 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 \ @@ -127,7 +136,8 @@ FROM wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d7 USER root RUN if command -v git > /dev/null; then \ - git config --global --add safe.directory /var/www; \ + 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 \ From c29dfbc149a27af8e154feca784a92a2d21aa2ef Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 00:24:16 +0000 Subject: [PATCH 038/142] Prevent temp tables clobbering PostgreSQL metadata --- .../postgresql/class-wp-postgresql-driver.php | 35 +++- .../tests/WP_PostgreSQL_DB_Tests.php | 150 +++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 80 ++++++++ .../postgresql/class-wp-postgresql-db.php | 182 ++++++++++++++++-- 4 files changed, 422 insertions(+), 25 deletions(-) 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 index 665799033..06746fc80 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -304,7 +304,9 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $this->is_create_table_query( $query ) ) { $translator = new WP_PostgreSQL_Create_Table_Translator(); $result = $this->execute_postgresql_statements( $translator->translate_schema( $query ) ); - $this->store_mysql_schema_metadata( $query ); + if ( ! $this->is_temporary_create_table_query( $query ) ) { + $this->store_mysql_schema_metadata( $query ); + } return $result; } @@ -318,7 +320,9 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $drop_query = $this->translate_mysql_drop_table_query( $query ); if ( null !== $drop_query ) { $result = $this->execute_postgresql_statements( $drop_query['statements'] ); - $this->delete_mysql_schema_metadata_for_tables( $drop_query['tables'] ); + if ( ! $drop_query['temporary'] ) { + $this->delete_mysql_schema_metadata_for_tables( $drop_query['tables'] ); + } return $result; } @@ -630,6 +634,10 @@ private function mysql_column_metadata_column_exists( string $column_name ): boo * @param string $query MySQL CREATE TABLE query. */ public function store_mysql_schema_metadata( string $query ): void { + if ( $this->is_temporary_create_table_query( $query ) ) { + return; + } + $this->ensure_mysql_schema_metadata_tables(); $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query, true ); @@ -1205,10 +1213,10 @@ private function is_postgresql_integer_family_data_type( string $data_type ): bo * Translate supported DROP TABLE statements and expose dropped table names. * * @param string $query MySQL DROP TABLE query. - * @return array{statements: string[], tables: string[]}|null Translation, or null when unsupported. + * @return array{statements: string[], tables: string[], temporary: bool}|null Translation, or null when unsupported. */ private function translate_mysql_drop_table_query( string $query ): ?array { - if ( ! preg_match( '/^\s*DROP\s+(?:TEMPORARY\s+)?TABLE\s+(IF\s+EXISTS\s+)?(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { + if ( ! preg_match( '/^\s*DROP\s+(?PTEMPORARY\s+)?TABLE\s+(?PIF\s+EXISTS\s+)?(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { return null; } @@ -1221,7 +1229,7 @@ private function translate_mysql_drop_table_query( string $query ): ?array { 'statements' => array( sprintf( 'DROP TABLE %s%s', - '' !== $matches[1] ? 'IF EXISTS ' : '', + ! empty( $matches['if_exists'] ) ? 'IF EXISTS ' : '', implode( ', ', array_map( @@ -1232,6 +1240,7 @@ private function translate_mysql_drop_table_query( string $query ): ?array { ), ), 'tables' => $table_names, + 'temporary' => ! empty( $matches['temporary'] ), ); } @@ -4518,6 +4527,22 @@ private function is_create_table_query( string $query ): bool { && $this->has_mysql_create_table_marker( $tokens ); } + /** + * Check whether a CREATE TABLE query creates a temporary table. + * + * @param string $query MySQL query. + * @return bool Whether the query is CREATE TEMPORARY TABLE. + */ + private function is_temporary_create_table_query( string $query ): bool { + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + /** * Check whether a CREATE TABLE query contains MySQL install-schema syntax. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 9bc4f895e..4ceadaeca 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -318,6 +318,156 @@ public function get_recorded_queries(): array { ); } + /** + * Tests temp table charset lookups prefer the temp schema over stored permanent metadata. + */ + public function test_get_col_charset_prefers_temporary_table_schema_over_stored_permanent_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) && array( 'pg_temp_42', 'wptests_shadow_charset' ) === $params ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'temp_value', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 1, + ), + ) + ); + } + + if ( false !== strpos( $sql, WP_PostgreSQL_DB::MYSQL_CHARSET_METADATA_TABLE ) ) { + $this->queries[] = 'stored_charset_metadata'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'permanent_value', + 'column_type' => 'text', + 'collation_name' => 'latin1_swedish_ci', + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Temp_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +wp_postgresql_db_test_respond( + array( + 'charset' => $db->get_col_charset( 'wptests_shadow_charset', 'temp_value' ), + 'queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'charset' => 'utf8mb4', + 'queries' => array( + 'temp_schema', + 'native_temp_columns', + ), + ), + $result + ); + } + /** * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 888c80494..7217ad029 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -163,6 +163,42 @@ public function test_create_temporary_table_with_character_set_is_translated_to_ ); } + /** + * Tests temporary DDL does not clobber permanent MySQL schema metadata. + */ + public function test_temporary_create_and_drop_do_not_clobber_permanent_mysql_schema_metadata(): void { + $driver = $this->create_driver(); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_shadow_metadata ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + body longtext CHARACTER SET koi8r COLLATE koi8r_general_ci NOT NULL, + PRIMARY KEY (id), + KEY title (title(20)) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ); + $indexes_before = $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ); + + $this->assertSame( array( 'id', 'title', 'body' ), array_column( $columns_before, 'column_name' ) ); + $this->assertSame( 'longtext', $columns_before[2]['column_type'] ); + $this->assertSame( 'koi8r_general_ci', $columns_before[2]['collation_name'] ); + $this->assertSame( array( 'PRIMARY', 'title' ), array_values( array_unique( array_column( $indexes_before, 'key_name' ) ) ) ); + $this->assertSame( '20', $indexes_before[1]['sub_part'] ); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_shadow_metadata (temp_value varchar(10) CHARACTER SET latin1)' ); + + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + + $driver->query( 'DROP TEMPORARY TABLE wptests_shadow_metadata' ); + + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + } + /** * Tests plain CHAR columns do not route through the MySQL DDL translator. */ @@ -1548,6 +1584,50 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Get stored MySQL column metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @return array Stored metadata rows. + */ + private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT column_name, column_type, character_set_name, collation_name, is_nullable, column_default, extra + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( 'public', $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get stored MySQL index metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @return array Stored metadata rows. + */ + private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT key_name, seq_in_index, column_name, non_unique, index_type, sub_part, nullable + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY index_ordinal, seq_in_index', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE ) + ), + array( 'public', $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + /** * Creates a PostgreSQL driver with SHOW INDEX fixture rows. * diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index c7fdc4ff3..d6c7241f2 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -519,7 +519,19 @@ private function get_postgresql_php_encoding_for_mysql_charset( string $charset * @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() || ! class_exists( 'WP_PostgreSQL_Create_Table_Translator', false ) ) { + if ( ! $this->has_usable_postgresql_connection() ) { + return; + } + + if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { + $table_name = $this->get_postgresql_create_table_name( $query ); + if ( null !== $table_name ) { + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + } + return; + } + + if ( ! class_exists( 'WP_PostgreSQL_Create_Table_Translator', false ) ) { return; } @@ -601,6 +613,11 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query return; } + if ( $this->is_postgresql_drop_temporary_table_query( $query ) ) { + $this->clear_postgresql_table_charset_cache( $tables ); + return; + } + try { if ( ! $this->postgresql_charset_metadata_table_exists() ) { return; @@ -622,6 +639,18 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query } } + /** + * 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 ] ); + } + } + /** * Ensure the PostgreSQL side table for MySQL charset metadata exists. */ @@ -654,6 +683,93 @@ private function is_postgresql_mysql_charset_metadata_create_query( string $quer return 1 === preg_match( '/\b(?:CHARSET|CHARACTER\s+SET|COLLATE|ASCII|UNICODE|BINARY)\b/i', $query ); } + /** + * Check whether a CREATE TABLE query creates a temporary table. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query is CREATE TEMPORARY TABLE. + */ + private function is_postgresql_create_temporary_table_query( string $query ): bool { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return false; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + + /** + * Get the table name from a CREATE TABLE query. + * + * @param string $query CREATE TABLE query. + * @return string|null Table name, or null when unavailable. + */ + private function get_postgresql_create_table_name( string $query ): ?string { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return null; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + if ( + ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + ) { + return null; + } + + return $tokens[ $position ]->get_value(); + } + + /** + * Check whether a DROP TABLE query targets temporary tables. + * + * @param string $query DROP TABLE query. + * @return bool Whether the query is DROP TEMPORARY TABLE. + */ + private function is_postgresql_drop_temporary_table_query( string $query ): bool { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return false; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::DROP_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + /** * Check whether the side table for MySQL charset metadata exists. * @@ -687,6 +803,15 @@ private function get_postgresql_column_charset_metadata( string $table ) { return false; } + $temp_schema = $this->get_postgresql_temporary_table_schema( $table ); + if ( false === $temp_schema ) { + return false; + } + + if ( null !== $temp_schema ) { + return $this->get_native_postgresql_column_charset_metadata( $table, $temp_schema ); + } + $columns = $this->get_stored_postgresql_column_charset_metadata( $table ); if ( false !== $columns && ! empty( $columns ) ) { return $columns; @@ -695,6 +820,33 @@ private function get_postgresql_column_charset_metadata( string $table ) { return $this->get_native_postgresql_column_charset_metadata( $table ); } + /** + * Get the active temporary schema for a table name. + * + * @param string $table Table name. + * @return string|null|false Temporary schema, null when not temporary, or false on failure. + */ + private function get_postgresql_temporary_table_schema( string $table ) { + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT n.nspname + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.oid = pg_my_temp_schema() + AND lower(c.relname) = lower(?) + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', + array( $this->normalize_postgresql_table_name( $table ) ) + ); + $schema = $stmt->fetchColumn(); + } catch ( Throwable $e ) { + return false; + } + + return false === $schema ? null : (string) $schema; + } + /** * Load previously stored MySQL charset metadata. * @@ -729,30 +881,20 @@ private function get_stored_postgresql_column_charset_metadata( string $table ) * @param string $table Table name. * @return array|false Column metadata, or false when unavailable. */ - private function get_native_postgresql_column_charset_metadata( string $table ) { + 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 = ( - SELECT nspname - FROM pg_catalog.pg_namespace - WHERE oid = pg_my_temp_schema() - ) - OR table_schema = current_schema() - ) + WHERE table_schema = ' . $table_schema_sql . ' AND lower(table_name) = lower(?) - ORDER BY CASE - WHEN table_schema = ( - SELECT nspname - FROM pg_catalog.pg_namespace - WHERE oid = pg_my_temp_schema() - ) THEN 0 - ELSE 1 - END, - ordinal_position', - array( $this->normalize_postgresql_table_name( $table ) ) + ORDER BY ordinal_position', + $params ); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); } catch ( Throwable $e ) { From df5aa771e8a6f16bac8119e0cd2e58523071375d Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 00:38:41 +0000 Subject: [PATCH 039/142] Constrain temporary PostgreSQL drops to temp schema --- .../postgresql/class-wp-postgresql-driver.php | 61 ++++++++------ .../tests/WP_PostgreSQL_Driver_Tests.php | 80 ++++++++++++++++++- 2 files changed, 114 insertions(+), 27 deletions(-) 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 index 06746fc80..34317ceb7 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -1225,25 +1225,52 @@ private function translate_mysql_drop_table_query( string $query ): ?array { return null; } + $temporary = ! empty( $matches['temporary'] ); + $table_identifiers = array(); + foreach ( $table_names as $table_name ) { + $table_identifiers[] = $temporary + ? $this->get_temporary_drop_table_identifier( $table_name ) + : $this->connection->quote_identifier( $table_name ); + } + return array( 'statements' => array( sprintf( 'DROP TABLE %s%s', ! empty( $matches['if_exists'] ) ? 'IF EXISTS ' : '', - implode( - ', ', - array_map( - array( $this->connection, 'quote_identifier' ), - $table_names - ) - ) + implode( ', ', $table_identifiers ) ), ), 'tables' => $table_names, - 'temporary' => ! empty( $matches['temporary'] ), + 'temporary' => $temporary, ); } + /** + * Get the backend table identifier for a MySQL DROP TEMPORARY TABLE target. + * + * @param string $table_name MySQL table identifier value. + * @return string PostgreSQL table identifier constrained to the temporary schema. + */ + private function get_temporary_drop_table_identifier( string $table_name ): string { + return $this->get_temporary_drop_table_schema_name() . '.' . $this->connection->quote_identifier( $table_name ); + } + + /** + * Get the backend temporary schema name. + * + * @return string Backend temporary schema name. + */ + private function get_temporary_drop_table_schema_name(): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if ( 'sqlite' === $driver_name ) { + return 'temp'; + } + + return 'pg_temp'; + } + /** * Translate a MySQL column definition fragment via the CREATE TABLE translator. * @@ -2757,24 +2784,6 @@ private function reset_query_state(): void { $this->last_postgresql_queries = array(); } - /** - * Translate MySQL DROP TEMPORARY TABLE cleanup statements. - * - * @param string $query MySQL query. - * @return string|null PostgreSQL query, or null when unsupported. - */ - private function translate_mysql_drop_temporary_table_query( string $query ): ?string { - if ( ! preg_match( '/^\s*DROP\s+TEMPORARY\s+TABLE\s+(IF\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { - return null; - } - - return sprintf( - 'DROP TABLE %s%s', - '' !== $matches[1] ? 'IF EXISTS ' : '', - $this->connection->quote_identifier( $matches[2] ) - ); - } - /** * Translate the WordPress options cleanup DELETE ... REGEXP 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 index 7217ad029..4abfd1791 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -132,16 +132,66 @@ public function test_drop_temporary_table_is_translated_to_postgresql(): void { $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_cleanup (value TEXT)' ); + $this->assertTrue( $this->sqlite_table_exists( $driver, 'temp', 'wptests_temp_cleanup' ) ); $this->assertSame( 0, $driver->query( 'DROP TEMPORARY TABLE wptests_temp_cleanup' ) ); $this->assertSame( array( array( - 'sql' => 'DROP TABLE "wptests_temp_cleanup"', + 'sql' => 'DROP TABLE temp."wptests_temp_cleanup"', 'params' => array(), ), ), $driver->get_last_postgresql_queries() ); + $this->assertFalse( $this->sqlite_table_exists( $driver, 'temp', 'wptests_temp_cleanup' ) ); + } + + /** + * Tests temporary drops never delete a permanent table when no temp table exists. + */ + public function test_drop_temporary_table_without_matching_temp_table_does_not_drop_permanent_table(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_permanent_temp_probe (id INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_permanent_temp_probe (id int NOT NULL)' ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_probe' ); + + try { + $driver->query( 'DROP TEMPORARY TABLE wptests_permanent_temp_probe' ); + $this->fail( 'DROP TEMPORARY TABLE without an active temp table should fail without dropping the permanent table.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_permanent_temp_probe' ) ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_probe' ) ); + } + + /** + * Tests temporary IF EXISTS drops no-op without deleting a permanent table. + */ + public function test_drop_temporary_table_if_exists_without_matching_temp_table_does_not_drop_permanent_table(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_permanent_temp_exists_probe (id INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_permanent_temp_exists_probe (id int NOT NULL)' ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_exists_probe' ); + + $driver->query( 'DROP TEMPORARY TABLE IF EXISTS wptests_permanent_temp_exists_probe' ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE IF EXISTS temp."wptests_permanent_temp_exists_probe"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_permanent_temp_exists_probe' ) ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_exists_probe' ) ); } /** @@ -1584,6 +1634,34 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Check whether an injected SQLite backend table exists. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $schema SQLite schema name. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_exists( WP_PostgreSQL_Driver $driver, string $schema, string $table_name ): bool { + if ( 'temp' === $schema ) { + $catalog = 'sqlite_temp_master'; + } elseif ( 'main' === $schema ) { + $catalog = 'sqlite_master'; + } else { + throw new InvalidArgumentException( 'Unsupported SQLite schema for test table lookup.' ); + } + + $stmt = $driver->get_connection()->query( + sprintf( + "SELECT name FROM %s WHERE type = 'table' AND name = ?", + $catalog + ), + array( $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + /** * Get stored MySQL column metadata rows for a table. * From 0a8f23517abda65486bd41657646a6c0e246e33f Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 00:50:11 +0000 Subject: [PATCH 040/142] Clean up PostgreSQL PHP 7.2 compatibility --- .../postgresql/class-wp-postgresql-driver.php | 40 +++++++++---------- .../tests/WP_PostgreSQL_DB_Tests.php | 11 ++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 10 ++--- 3 files changed, 34 insertions(+), 27 deletions(-) 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 index 34317ceb7..73d6fbef0 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -702,9 +702,9 @@ private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { $table_name = $metadata['table']; if ( 'add_column' === $metadata['operation'] ) { - $column = $metadata['column']; - $column['ordinal'] = $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); - $column_nullable = array( strtolower( $column['name'] ) => $column['nullable'] ?? 'YES' ); + $column = $metadata['column']; + $column['ordinal'] = $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); + $column_nullable = array( strtolower( $column['name'] ) => $column['nullable'] ?? 'YES' ); $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); foreach ( $metadata['indexes'] ?? array() as $index ) { $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); @@ -1492,8 +1492,8 @@ private function handle_mysql_procedure_query( string $query, $fetch_mode = PDO: if ( preg_match( '/^\s*CREATE\s+PROCEDURE\s+`?([A-Za-z0-9_]+)`?\s*\(\s*\)\s+BEGIN\s+(.*?)\s*;\s*END\s*;?\s*$/is', $query, $matches ) ) { $this->procedures[ strtolower( $matches[1] ) ] = trim( $matches[2] ); - $this->last_result = 0; - $this->last_column_meta = array(); + $this->last_result = 0; + $this->last_column_meta = array(); return $this->last_result; } @@ -1739,9 +1739,9 @@ private function get_show_columns_query( string $query ): ?array { return array( 'schema' => $schema_name, - 'table' => $table_name, - 'full' => $is_full, - 'like' => $like, + 'table' => $table_name, + 'full' => $is_full, + 'like' => $like, ); } @@ -3459,7 +3459,7 @@ private function translate_distinct_order_by_query( string $query ): ?string { return null; } - $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $statement_end ); + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $statement_end ); $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $statement_end ); if ( null === $from_position @@ -4567,17 +4567,17 @@ private function has_mysql_create_table_marker( array $tokens ): bool { if ( in_array( $token->id, - array( - WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, - WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, - WP_MySQL_Lexer::CHARSET_SYMBOL, - WP_MySQL_Lexer::COLLATE_SYMBOL, - WP_MySQL_Lexer::ENGINE_SYMBOL, - WP_MySQL_Lexer::FULLTEXT_SYMBOL, - WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, - WP_MySQL_Lexer::SPATIAL_SYMBOL, - WP_MySQL_Lexer::UNSIGNED_SYMBOL, - ), + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::COLLATE_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), true ) ) { diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 4ceadaeca..95a8947b8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -198,7 +198,7 @@ public function check_ascii( $text ) { 'tis620' => bin2hex( str_repeat( "\xcc\xe3", 5 ) ), ), $result - ); + ); } /** @@ -595,7 +595,14 @@ public function setAttribute( $attribute, $value ): bool { return true; } - public function getAttribute( $attribute ): mixed { + /** + * Get a fake PDO attribute. + * + * @param int $attribute PDO attribute. + * @return mixed Attribute value. + */ + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { if ( PDO::ATTR_DRIVER_NAME === $attribute ) { return 'pgsql'; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 4abfd1791..4a2d037bf 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -674,7 +674,7 @@ public function test_wordpress_expired_transients_delete_is_translated_to_postgr $this->assertCount( 1, $queries ); $this->assertStringContainsString( 'WITH expired_transients AS', $queries[0]['sql'] ); $this->assertStringContainsString( 'DELETE FROM "wptests_options"', $queries[0]['sql'] ); - $this->assertStringContainsString( "SUBSTR(a.option_name, 12)", $queries[0]['sql'] ); + $this->assertStringContainsString( 'SUBSTR(a.option_name, 12)', $queries[0]['sql'] ); $rows = $driver->query( 'SELECT option_name FROM wptests_options ORDER BY option_name' ); @@ -1091,10 +1091,10 @@ public function test_show_full_columns_returns_mysql_shaped_catalog_rows(): void */ public function test_show_columns_accepts_table_qualification_forms(): void { $cases = array( - 'SHOW COLUMNS IN wptests_options' => array( 'public', 'wptests_options' ), - 'SHOW COLUMNS FROM public.wptests_options' => array( 'public', 'wptests_options' ), - 'SHOW COLUMNS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), - 'SHOW COLUMNS IN wptests_options IN public' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM public.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS IN wptests_options IN public' => array( 'public', 'wptests_options' ), ); foreach ( $cases as $query => $params ) { From 1d388500225e400f8d9d992978f66b01f21acb44 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 10:02:44 +0000 Subject: [PATCH 041/142] Fix PostgreSQL dbDelta temporary table metadata --- .../postgresql/class-wp-postgresql-driver.php | 176 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 79 +++++++- 2 files changed, 237 insertions(+), 18 deletions(-) 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 index 73d6fbef0..a2f96e9bc 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -304,7 +304,9 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $this->is_create_table_query( $query ) ) { $translator = new WP_PostgreSQL_Create_Table_Translator(); $result = $this->execute_postgresql_statements( $translator->translate_schema( $query ) ); - if ( ! $this->is_temporary_create_table_query( $query ) ) { + if ( $this->is_temporary_create_table_query( $query ) ) { + $this->store_mysql_temporary_schema_metadata( $query ); + } else { $this->store_mysql_schema_metadata( $query ); } return $result; @@ -319,10 +321,12 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $drop_query = $this->translate_mysql_drop_table_query( $query ); if ( null !== $drop_query ) { + $metadata_targets = $this->get_mysql_schema_metadata_drop_targets( + $drop_query['tables'], + $drop_query['temporary'] + ); $result = $this->execute_postgresql_statements( $drop_query['statements'] ); - if ( ! $drop_query['temporary'] ) { - $this->delete_mysql_schema_metadata_for_tables( $drop_query['tables'] ); - } + $this->delete_mysql_schema_metadata_for_table_targets( $metadata_targets ); return $result; } @@ -638,33 +642,73 @@ public function store_mysql_schema_metadata( string $query ): void { return; } + $this->store_mysql_schema_metadata_for_schema( $query, 'public' ); + } + + /** + * Store MySQL-facing schema metadata for translated CREATE TEMPORARY TABLE statements. + * + * @param string $query MySQL CREATE TEMPORARY TABLE query. + */ + private function store_mysql_temporary_schema_metadata( string $query ): void { + $this->store_mysql_schema_metadata_for_schema( + $query, + array( $this, 'get_temporary_schema_for_metadata_table' ) + ); + } + + /** + * Store MySQL-facing schema metadata for translated CREATE TABLE statements in one backend schema. + * + * @param string $query MySQL CREATE TABLE query. + * @param string|callable $table_schema Metadata schema, or resolver receiving the table name. + */ + private function store_mysql_schema_metadata_for_schema( string $query, $table_schema ): void { $this->ensure_mysql_schema_metadata_tables(); $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query, true ); foreach ( $metadata_tables as $metadata ) { - $table_schema = 'public'; - $table_name = $metadata['table_name']; + $schema_name = is_callable( $table_schema ) + ? (string) call_user_func( $table_schema, $metadata['table_name'] ) + : (string) $table_schema; + $table_name = $metadata['table_name']; - $this->delete_mysql_schema_metadata_for_tables( array( $table_name ) ); + $this->delete_mysql_schema_metadata_for_tables( array( $table_name ), $schema_name ); $column_nullable = array(); foreach ( $metadata['columns'] as $column ) { - $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + $this->insert_mysql_column_metadata( $schema_name, $table_name, $column ); $column_nullable[ strtolower( $column['name'] ) ] = $column['nullable'] ?? 'YES'; } foreach ( $metadata['indexes'] ?? array() as $index ) { - $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + $this->insert_mysql_index_metadata( $schema_name, $table_name, $index, $column_nullable ); } } } + /** + * Get the metadata schema name for an active temporary table. + * + * @param string $table_name Table name. + * @return string Metadata schema name. + */ + private function get_temporary_schema_for_metadata_table( string $table_name ): string { + $schema_name = $this->get_active_temporary_table_schema( $table_name ); + if ( null !== $schema_name ) { + return $schema_name; + } + + return $this->get_temporary_drop_table_schema_name(); + } + /** * Delete stored MySQL schema metadata for dropped tables. * * @param string[] $table_names Table names. + * @param string $table_schema Metadata schema name. */ - private function delete_mysql_schema_metadata_for_tables( array $table_names ): void { + private function delete_mysql_schema_metadata_for_tables( array $table_names, string $table_schema = 'public' ): void { if ( empty( $table_names ) ) { return; } @@ -672,7 +716,7 @@ private function delete_mysql_schema_metadata_for_tables( array $table_names ): $this->ensure_mysql_schema_metadata_tables(); foreach ( $table_names as $table_name ) { - $params = array( 'public', $table_name ); + $params = array( $table_schema, $table_name ); $this->connection->query( sprintf( 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', @@ -690,6 +734,20 @@ private function delete_mysql_schema_metadata_for_tables( array $table_names ): } } + /** + * Delete stored MySQL schema metadata for concrete schema/table targets. + * + * @param array[] $targets Metadata targets. + */ + private function delete_mysql_schema_metadata_for_table_targets( array $targets ): void { + foreach ( $targets as $target ) { + $this->delete_mysql_schema_metadata_for_tables( + array( $target['table'] ), + $target['schema'] + ); + } + } + /** * Apply metadata changes for a translated dbDelta ALTER TABLE statement. * @@ -1246,6 +1304,37 @@ private function translate_mysql_drop_table_query( string $query ): ?array { ); } + /** + * Get the MySQL metadata rows that should be removed after a DROP TABLE. + * + * @param string[] $table_names Table names. + * @param bool $temporary Whether the DROP TABLE explicitly targets temporary tables. + * @return array[] Metadata targets. + */ + private function get_mysql_schema_metadata_drop_targets( array $table_names, bool $temporary ): array { + $targets = array(); + + foreach ( $table_names as $table_name ) { + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); + if ( null !== $temporary_schema ) { + $targets[] = array( + 'schema' => $temporary_schema, + 'table' => $table_name, + ); + continue; + } + + if ( ! $temporary ) { + $targets[] = array( + 'schema' => 'public', + 'table' => $table_name, + ); + } + } + + return $targets; + } + /** * Get the backend table identifier for a MySQL DROP TEMPORARY TABLE target. * @@ -1850,7 +1939,10 @@ private function execute_describe_query( string $table_name, $fetch_mode, ...$fe $this->ensure_mysql_schema_metadata_tables(); $sql = $this->get_describe_catalog_query(); - $params = array( 'public', $table_name ); + $params = array( + $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ), + $table_name, + ); $stmt = $this->connection->query( $sql, $params ); $this->last_postgresql_queries[] = array( @@ -1878,7 +1970,10 @@ private function execute_show_columns_query( string $schema_name, string $table_ $this->ensure_mysql_schema_metadata_tables(); $sql = $this->get_show_columns_catalog_query( $is_full ); - $params = array( $schema_name, $table_name ); + $params = array( + $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ), + $table_name, + ); if ( null !== $like ) { $sql .= " AND field_name LIKE ? ESCAPE '\\'"; @@ -2145,7 +2240,10 @@ private function execute_show_index_query( string $table_name, ?string $key_name $this->ensure_mysql_schema_metadata_tables(); $sql = $this->get_show_index_catalog_query(); - $params = array( 'public', $table_name ); + $params = array( + $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ), + $table_name, + ); if ( null !== $key_name ) { $sql .= ' @@ -2175,6 +2273,56 @@ private function execute_show_index_query( string $table_name, ?string $key_name return $this->last_result; } + /** + * Resolve the backend schema for an unqualified MySQL table introspection query. + * + * @param string $schema_name Requested schema name. + * @param string $table_name Requested table name. + * @return string Backend schema name. + */ + private function resolve_mysql_table_schema_for_introspection( string $schema_name, string $table_name ): string { + if ( 'public' !== $schema_name ) { + return $schema_name; + } + + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); + return null === $temporary_schema ? $schema_name : $temporary_schema; + } + + /** + * Get the active temporary schema for a table name. + * + * @param string $table_name Table name. + * @return string|null Temporary schema name, or null when no active temporary table exists. + */ + private function get_active_temporary_table_schema( string $table_name ): ?string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if ( 'sqlite' === $driver_name ) { + $stmt = $this->connection->query( + "SELECT name FROM sqlite_temp_master WHERE type = 'table' AND LOWER(name) = LOWER(?) LIMIT 1", + array( $table_name ) + ); + + return false === $stmt->fetchColumn() ? null : 'temp'; + } + + $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; + } + /** * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 4a2d037bf..8f3eed390 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -249,6 +249,75 @@ public function test_temporary_create_and_drop_do_not_clobber_permanent_mysql_sc $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); } + /** + * Tests temporary CREATE TABLE stores isolated MySQL metadata for dbDelta introspection. + */ + public function test_temporary_create_stores_temporary_mysql_schema_metadata_for_dbdelta_introspection(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE wptests_dbdelta_temp_probe (permanent_value INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_dbdelta_temp_probe (permanent_value int NOT NULL)' ); + + $public_columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ); + $this->assertSame( array( 'permanent_value' ), array_column( $public_columns_before, 'column_name' ) ); + + $driver->query( + 'CREATE TEMPORARY TABLE wptests_dbdelta_temp_probe ( + `id` bigint(20) NOT NULL, + `references` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `compound_key` (`id`,`references`(191)) + )' + ); + + $temp_columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ); + $this->assertSame( array( 'id', 'references' ), array_column( $temp_columns, 'column_name' ) ); + $this->assertSame( array( 'bigint(20)', 'varchar(255)' ), array_column( $temp_columns, 'column_type' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ) ); + + $describe = $driver->query( 'DESCRIBE wptests_dbdelta_temp_probe' ); + $this->assertSame( 'id', $describe[0]->Field ); + $this->assertSame( 'PRI', $describe[0]->Key ); + $this->assertSame( 'references', $describe[1]->Field ); + $this->assertSame( 'MUL', $describe[1]->Key ); + $this->assertSame( array( 'temp', 'wptests_dbdelta_temp_probe' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $temp_indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ); + $this->assertSame( array( 'PRIMARY', 'compound_key', 'compound_key' ), array_column( $temp_indexes, 'key_name' ) ); + $this->assertSame( array( 'id', 'id', 'references' ), array_column( $temp_indexes, 'column_name' ) ); + $this->assertSame( '191', $temp_indexes[2]['sub_part'] ); + + $driver->query( 'DROP TEMPORARY TABLE wptests_dbdelta_temp_probe' ); + + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ) ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ) ); + } + + /** + * Tests unqualified DROP TABLE removes active temporary metadata before permanent metadata. + */ + public function test_unqualified_drop_table_removes_temporary_metadata_without_clobbering_permanent_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_dbdelta_shadow_drop (permanent_value INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_dbdelta_shadow_drop (permanent_value int NOT NULL)' ); + + $public_columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop' ); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_dbdelta_shadow_drop (`temp_value` varchar(50) NOT NULL)' ); + $temp_columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop', 'temp' ); + $this->assertSame( array( 'temp_value' ), array_column( $temp_columns, 'column_name' ) ); + + $driver->query( 'DROP TABLE IF EXISTS wptests_dbdelta_shadow_drop' ); + + $this->assertFalse( $this->sqlite_table_exists( $driver, 'temp', 'wptests_dbdelta_shadow_drop' ) ); + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_dbdelta_shadow_drop' ) ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop', 'temp' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop' ) ); + } + /** * Tests plain CHAR columns do not route through the MySQL DDL translator. */ @@ -1667,9 +1736,10 @@ private function sqlite_table_exists( WP_PostgreSQL_Driver $driver, string $sche * * @param WP_PostgreSQL_Driver $driver Driver under test. * @param string $table_name Table name. + * @param string $schema Metadata schema name. * @return array Stored metadata rows. */ - private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name ): array { + private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { $stmt = $driver->get_connection()->query( sprintf( 'SELECT column_name, column_type, character_set_name, collation_name, is_nullable, column_default, extra @@ -1678,7 +1748,7 @@ private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, s ORDER BY ordinal_position', $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) ), - array( 'public', $table_name ) + array( $schema, $table_name ) ); return $stmt->fetchAll( PDO::FETCH_ASSOC ); @@ -1689,9 +1759,10 @@ private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, s * * @param WP_PostgreSQL_Driver $driver Driver under test. * @param string $table_name Table name. + * @param string $schema Metadata schema name. * @return array Stored metadata rows. */ - private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name ): array { + private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { $stmt = $driver->get_connection()->query( sprintf( 'SELECT key_name, seq_in_index, column_name, non_unique, index_type, sub_part, nullable @@ -1700,7 +1771,7 @@ private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, st ORDER BY index_ordinal, seq_in_index', $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE ) ), - array( 'public', $table_name ) + array( $schema, $table_name ) ); return $stmt->fetchAll( PDO::FETCH_ASSOC ); From 1bf4852cf291c8e1a4477b74e75b47f2e4ac6dae Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 10:06:06 +0000 Subject: [PATCH 042/142] Preserve PostgreSQL charset metadata for temp tables --- .../tests/WP_PostgreSQL_DB_Tests.php | 318 ++++++++++++++++++ .../postgresql/class-wp-postgresql-db.php | 138 +++++++- 2 files changed, 449 insertions(+), 7 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 95a8947b8..fcabd0a61 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -468,6 +468,324 @@ public function get_connection(): WP_PostgreSQL_Connection { ); } + /** + * Tests temp table charset lookups use metadata from the temporary CREATE TABLE query. + */ + public function test_get_charset_uses_temporary_create_table_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + 'CREATE TEMPORARY TABLE wptests_temp_declared_charset ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET koi8r )' +); + +$get_table_charset = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_table_charset' ); +$get_table_charset->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'table_charset' => $get_table_charset->invoke( $db, 'wptests_temp_declared_charset' ), + 'column_a_charset' => $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ), + 'column_b_charset' => $db->get_col_charset( 'WPTESTS_TEMP_DECLARED_CHARSET', 'B' ), + 'connection_queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'table_charset' => 'ascii', + 'column_a_charset' => 'big5', + 'column_b_charset' => 'koi8r', + 'connection_queries' => array( + 'temp_schema', + ), + ), + $result + ); + } + + /** + * Tests charset lookups can use MySQL metadata stored by the PostgreSQL driver. + */ + public function test_get_charset_uses_driver_show_columns_metadata_when_adapter_metadata_is_absent(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 'SHOW FULL COLUMNS FROM `wptests_declared_charset`' !== $query ) { + return array(); + } + + $rows = array( + array( + 'Field' => 'a', + 'Type' => 'varchar(50)', + 'Collation' => 'utf8_unicode_ci', + ), + array( + 'Field' => 'b', + 'Type' => 'text', + 'Collation' => 'big5_chinese_ci', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + return $rows; + } + + return array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$get_table_charset = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_table_charset' ); +$get_table_charset->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'table_charset' => $get_table_charset->invoke( $db, 'wptests_declared_charset' ), + 'column_a_charset' => $db->get_col_charset( 'wptests_declared_charset', 'a' ), + 'column_b_charset' => $db->get_col_charset( 'WPTESTS_DECLARED_CHARSET', 'B' ), + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'table_charset' => 'ascii', + 'column_a_charset' => 'utf8', + 'column_b_charset' => 'big5', + 'connection_queries' => array( + 'temp_schema', + 'metadata_exists', + ), + 'driver_queries' => array( + 'SHOW FULL COLUMNS FROM `wptests_declared_charset`', + ), + ), + $result + ); + } + /** * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. */ diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index d6c7241f2..59471ef61 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -33,6 +33,13 @@ class WP_PostgreSQL_DB extends wpdb { */ protected $dbh; + /** + * MySQL charset metadata for PostgreSQL temporary tables. + * + * @var array + */ + private $postgresql_temporary_charset_metadata = array(); + /** * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. * @@ -523,19 +530,55 @@ private function store_postgresql_create_table_charset_metadata( string $query ) return; } - if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { - $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 ) ) { + if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { + $table_name = $this->get_postgresql_create_table_name( $query ); + if ( null !== $table_name ) { + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + } } return; } - if ( ! class_exists( 'WP_PostgreSQL_Create_Table_Translator', false ) ) { + if ( ! $this->is_postgresql_mysql_charset_metadata_create_query( $query ) ) { + if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { + $table_name = $this->get_postgresql_create_table_name( $query ); + if ( null !== $table_name ) { + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + } + } return; } - if ( ! $this->is_postgresql_mysql_charset_metadata_create_query( $query ) ) { + if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { + try { + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query ); + foreach ( $metadata as $table ) { + if ( empty( $table['table_name'] ) ) { + continue; + } + + $table_name = (string) $table['table_name']; + $tablekey = $this->get_postgresql_metadata_key( $table_name ); + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + + $rows = array(); + foreach ( (array) ( $table['columns'] ?? array() ) as $column ) { + $rows[] = array( + 'column_name' => (string) $column['name'], + 'column_type' => (string) $column['type'], + 'collation_name' => $column['collation'], + ); + } + + $columns = $this->format_postgresql_charset_column_rows( $rows ); + if ( ! empty( $columns ) ) { + $this->postgresql_temporary_charset_metadata[ $tablekey ] = $columns; + } + } + } catch ( Throwable $e ) { + return; + } return; } @@ -647,7 +690,11 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query 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 ] ); + unset( + $this->table_charset[ $tablekey ], + $this->col_meta[ $tablekey ], + $this->postgresql_temporary_charset_metadata[ $tablekey ] + ); } } @@ -809,6 +856,11 @@ private function get_postgresql_column_charset_metadata( string $table ) { } if ( null !== $temp_schema ) { + $tablekey = $this->get_postgresql_metadata_key( $table ); + if ( array_key_exists( $tablekey, $this->postgresql_temporary_charset_metadata ) ) { + return $this->postgresql_temporary_charset_metadata[ $tablekey ]; + } + return $this->get_native_postgresql_column_charset_metadata( $table, $temp_schema ); } @@ -817,6 +869,11 @@ private function get_postgresql_column_charset_metadata( string $table ) { return $columns; } + $columns = $this->get_driver_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && ! empty( $columns ) ) { + return $columns; + } + return $this->get_native_postgresql_column_charset_metadata( $table ); } @@ -875,6 +932,73 @@ private function get_stored_postgresql_column_charset_metadata( string $table ) return $this->format_postgresql_charset_column_rows( $rows ); } + /** + * Load MySQL charset metadata through the PostgreSQL driver's SHOW COLUMNS path. + * + * The driver stores MySQL-facing column metadata as part of CREATE TABLE + * translation. Reusing it keeps wpdb charset checks aligned with DESCRIBE and + * SHOW FULL COLUMNS without depending on the adapter side table being present. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_driver_postgresql_column_charset_metadata( string $table ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + $table_name = $this->normalize_postgresql_table_name( $table ); + if ( '' === $table_name ) { + return false; + } + + try { + $rows = $this->dbh->query( + 'SHOW FULL COLUMNS FROM ' . $this->quote_postgresql_mysql_identifier( $table_name ), + PDO::FETCH_ASSOC + ); + } catch ( Throwable $e ) { + return false; + } + + if ( ! is_array( $rows ) || empty( $rows ) ) { + return false; + } + + $metadata_rows = array(); + foreach ( $rows as $row ) { + if ( is_object( $row ) ) { + $row = get_object_vars( $row ); + } + + if ( ! is_array( $row ) || empty( $row['Field'] ) ) { + continue; + } + + $metadata_rows[] = array( + 'column_name' => $row['Field'], + 'column_type' => $row['Type'] ?? '', + 'collation_name' => $row['Collation'] ?? null, + ); + } + + if ( empty( $metadata_rows ) ) { + return false; + } + + return $this->format_postgresql_charset_column_rows( $metadata_rows ); + } + + /** + * Quote an identifier for a MySQL statement handled by the PostgreSQL driver. + * + * @param string $identifier Identifier. + * @return string Backtick-quoted MySQL identifier. + */ + private function quote_postgresql_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + /** * Synthesize MySQL metadata from PostgreSQL catalogs when side metadata is absent. * From e218b77a6947176b765a84bbe0e40c94817a3a30 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 10:14:04 +0000 Subject: [PATCH 043/142] Fix PostgreSQL driver assignment alignment --- .../src/postgresql/class-wp-postgresql-driver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a2f96e9bc..f4d651d70 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -325,7 +325,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $drop_query['tables'], $drop_query['temporary'] ); - $result = $this->execute_postgresql_statements( $drop_query['statements'] ); + $result = $this->execute_postgresql_statements( $drop_query['statements'] ); $this->delete_mysql_schema_metadata_for_table_targets( $metadata_targets ); return $result; } From 0e6ac0bc78614a3e63ecbcda6202b43cf096ee79 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 11:00:15 +0000 Subject: [PATCH 044/142] Translate PostgreSQL limit and date extracts --- .../postgresql/class-wp-postgresql-driver.php | 240 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 197 ++++++++++++++ 2 files changed, 431 insertions(+), 6 deletions(-) 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 index f4d651d70..403d30e9f 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4212,15 +4212,17 @@ private function is_supported_simple_select_limit_clause( array $tokens, int $st * @return bool Whether the token is a supported non-negative integer. */ private function is_supported_simple_select_limit_number( WP_MySQL_Token $token ): bool { + $is_parameter_marker = WP_MySQL_Lexer::PARAM_MARKER === $token->id; return in_array( $token->id, array( WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::PARAM_MARKER, WP_MySQL_Lexer::ULONGLONG_NUMBER, ), true - ) && ctype_digit( $token->get_value() ); + ) && ( $is_parameter_marker || ctype_digit( $token->get_value() ) ); } /** @@ -4380,11 +4382,18 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in for ( $i = $start; $i < $end; $i++ ) { $token = $tokens[ $i ]; $fragment_token_id = $token->id; - $convert_expression = $this->translate_mysql_convert_using_to_postgresql( $tokens, $i, $end ); - if ( null !== $convert_expression ) { - $fragment = $convert_expression['sql']; - $fragment_token_id = $convert_expression['token_id']; - $i = $convert_expression['position']; + $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_convert_using_to_postgresql( $tokens, $i, $end ); + } + + if ( null !== $translated_fragment ) { + $fragment = $translated_fragment['sql']; + $fragment_token_id = $translated_fragment['token_id']; + $i = $translated_fragment['position']; } else { $fragment = $this->translate_mysql_token_to_postgresql( $token, $tokens[ $i + 1 ] ?? null ); } @@ -4403,6 +4412,217 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in return $sql; } + /** + * Translate MySQL LIMIT offset,count syntax to PostgreSQL LIMIT count OFFSET offset. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_limit_offset_count_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_limit_offset_count_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $sql = 'LIMIT ' . $tokens[ $bounds['count_position'] ]->get_bytes() + . ' OFFSET ' . $tokens[ $bounds['offset_position'] ]->get_bytes(); + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::LIMIT_SYMBOL, + 'position' => $bounds['count_position'], + ); + } + + /** + * Get token bounds for a MySQL LIMIT offset,count clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @param int $end Final token position, exclusive. + * @return array{offset_position: int, count_position: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_limit_offset_count_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position + 2 ]->id + || $position + 4 !== $end + || ! $this->is_supported_simple_select_limit_number( $tokens[ $position + 1 ] ) + || ! $this->is_supported_simple_select_limit_number( $tokens[ $position + 3 ] ) + ) { + return null; + } + + return array( + 'offset_position' => $position + 1, + 'count_position' => $position + 3, + ); + } + + /** + * Translate supported MySQL date/time extract functions to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_time_extract_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_extract_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( + 'CAST(EXTRACT(%s FROM CAST(%s AS timestamp)) AS integer)', + $bounds['unit'], + $expression_sql + ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL date/time extract forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_extract_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::EXTRACT_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_mysql_extract_keyword_bounds( $tokens, $position, $end ); + } + + return $this->get_mysql_date_time_function_bounds( $tokens, $position, $end ); + } + + /** + * Get token bounds for EXTRACT(unit FROM expr). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position EXTRACT token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_extract_keyword_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $unit = $this->get_mysql_date_time_extract_unit( $tokens[ $position + 2 ] ); + if ( null === $unit ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close || $position + 4 >= $after_close - 1 ) { + return null; + } + + return array( + 'unit' => $unit, + 'expression_start' => $position + 4, + 'expression_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Get token bounds for YEAR(expr), MONTH(expr), DAY(expr), and similar calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_time_function_bounds( array $tokens, int $position, int $end ): ?array { + $unit = $this->get_mysql_date_time_extract_unit( $tokens[ $position ] ); + if ( + null === $unit + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + if ( + $position + 2 >= $close_position + || null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ) + ) { + return null; + } + + return array( + 'unit' => $unit, + 'expression_start' => $position + 2, + 'expression_end' => $close_position, + 'close' => $close_position, + ); + } + + /** + * Get the PostgreSQL EXTRACT unit for a MySQL date/time token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string|null PostgreSQL EXTRACT unit, or null when unsupported. + */ + private function get_mysql_date_time_extract_unit( WP_MySQL_Token $token ): ?string { + switch ( $token->id ) { + case WP_MySQL_Lexer::YEAR_SYMBOL: + return 'YEAR'; + + case WP_MySQL_Lexer::MONTH_SYMBOL: + return 'MONTH'; + + case WP_MySQL_Lexer::DAY_SYMBOL: + case WP_MySQL_Lexer::DAYOFMONTH_SYMBOL: + return 'DAY'; + + case WP_MySQL_Lexer::HOUR_SYMBOL: + return 'HOUR'; + + case WP_MySQL_Lexer::MINUTE_SYMBOL: + return 'MINUTE'; + + case WP_MySQL_Lexer::SECOND_SYMBOL: + return 'SECOND'; + } + + return null; + } + /** * Translate a MySQL CONVERT(expr USING charset) expression to PostgreSQL. * @@ -4648,6 +4868,14 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_extract_function_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id && $this->should_quote_bare_mysql_identifier( $token->get_value() ) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 8f3eed390..3ee4d82e0 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -493,6 +493,87 @@ public function test_simple_select_with_mysql_offset_count_limit_is_translated_t ); } + /** + * 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 = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_SQL_Capture_Connection(), 'wptests' ); + + $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 ) { + $driver->query( $mysql_sql ); + + $this->assertSame( + array( + array( + 'sql' => $postgresql_sql, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + } + + /** + * Tests PostgreSQL LIMIT count OFFSET offset syntax is preserved. + */ + public function test_existing_limit_offset_clause_is_preserved(): void { + $driver = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_SQL_Capture_Connection(), 'wptests' ); + + $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. */ @@ -1031,6 +1112,63 @@ public function test_found_rows_returns_last_sql_calc_found_rows_count(): void { $this->assertSame( array(), $driver->get_last_postgresql_queries() ); } + /** + * Tests MySQL date/time extraction functions are translated for PostgreSQL. + */ + public function test_mysql_date_time_extract_functions_are_translated_to_postgresql(): void { + $driver = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection(), 'wptests' ); + + $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, DAYOFMONTH(post_date) AS d, DAY(post_date) AS day_value, HOUR(post_date) AS h, MINUTE(post_date) AS i, SECOND(post_date) AS s, EXTRACT(DAY FROM post_date) AS extracted_day FROM wptests_posts WHERE ID = 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2026', $rows[0]->y ); + $this->assertSame( '6', $rows[0]->m ); + $this->assertSame( '10', $rows[0]->d ); + $this->assertSame( '10', $rows[0]->day_value ); + $this->assertSame( '14', $rows[0]->h ); + $this->assertSame( '8', $rows[0]->i ); + $this->assertSame( '9', $rows[0]->s ); + $this->assertSame( '10', $rows[0]->extracted_day ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y, CAST(EXTRACT(MONTH FROM CAST(post_date AS timestamp)) AS integer) AS m, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS d, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS day_value, CAST(EXTRACT(HOUR FROM CAST(post_date AS timestamp)) AS integer) AS h, CAST(EXTRACT(MINUTE FROM CAST(post_date AS timestamp)) AS integer) AS i, CAST(EXTRACT(SECOND FROM CAST(post_date AS timestamp)) AS integer) AS s, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS extracted_day FROM wptests_posts WHERE "ID" = 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * 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 = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection(), 'wptests' ); + + $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"; + + $driver->query( $select ); + + $this->assertSame( + array( + array( + 'sql' => '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 CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) = 2026 AND CAST(EXTRACT(MONTH FROM CAST(post_date AS timestamp)) AS integer) = 6 AND CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) = 10', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + /** * Tests unsupported ON DUPLICATE KEY INSERT shapes still reach PDO. */ @@ -1871,6 +2009,65 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive } } +/** + * Fixture connection that records translated SELECT SQL without executing it. + */ +class WP_PostgreSQL_Driver_SQL_Capture_Connection extends WP_PostgreSQL_Connection { + /** + * Constructor. + */ + public function __construct() { + parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + } + + /** + * Execute a query, returning an empty result for captured SELECT statements. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( 0 === strpos( $sql, 'SELECT ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + return parent::query( $sql, $params ); + } +} + +/** + * Fixture connection that accepts PostgreSQL EXTRACT syntax in driver tests. + */ +class WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection extends WP_PostgreSQL_Driver_SQL_Capture_Connection { + /** + * Execute a query, returning MySQL-compatible date/time extract values. + * + * @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, 'EXTRACT(' ) ) { + $stmt = $this->get_pdo()->prepare( + 'SELECT + 2026 AS y, + 6 AS m, + 10 AS d, + 10 AS day_value, + 14 AS h, + 8 AS i, + 9 AS s, + 10 AS extracted_day' + ); + $stmt->execute(); + return $stmt; + } + + return parent::query( $sql, $params ); + } +} + /** * Fixture connection that accepts PostgreSQL ALTER TABLE syntax in driver tests. */ From e0b0ae053d0df675935ff879ce37566f391a3c17 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 11:15:12 +0000 Subject: [PATCH 045/142] Make PostgreSQL date extracts zero-date safe --- .../postgresql/class-wp-postgresql-driver.php | 84 ++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 221 ++++++++++-------- 2 files changed, 198 insertions(+), 107 deletions(-) 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 index 403d30e9f..17f1845da 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4380,8 +4380,8 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in $previous_token_id = null; for ( $i = $start; $i < $end; $i++ ) { - $token = $tokens[ $i ]; - $fragment_token_id = $token->id; + $token = $tokens[ $i ]; + $fragment_token_id = $token->id; $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); @@ -4483,16 +4483,86 @@ private function translate_mysql_date_time_extract_to_postgresql( array $tokens, ); return array( - 'sql' => sprintf( - 'CAST(EXTRACT(%s FROM CAST(%s AS timestamp)) AS integer)', - $bounds['unit'], - $expression_sql - ), + 'sql' => $this->get_postgresql_zero_date_safe_extract_sql( $bounds['unit'], $expression_sql ), 'token_id' => $tokens[ $position ]->id, 'position' => $bounds['close'], ); } + /** + * Get PostgreSQL SQL for a MySQL date/time extract that preserves MySQL zero-date behavior. + * + * PostgreSQL rejects MySQL zero-ish dates such as 0000-00-00 during timestamp + * casts. Detect those text-backed values first and extract the requested + * numeric part directly from the text; keep valid dates on PostgreSQL's + * timestamp EXTRACT path. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; + $zero_date_condition = 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 + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', + $zero_date_condition, + $this->get_postgresql_zero_date_extract_part_sql( $unit, $expression_text_sql ), + $unit, + $expression_sql + ); + } + + /** + * Get PostgreSQL SQL that extracts one part from a zero-ish MySQL date string. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_extract_part_sql( string $unit, string $expression_text_sql ): string { + $date_time_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + + switch ( $unit ) { + case 'YEAR': + return sprintf( 'CAST(SUBSTRING(%s FROM 1 FOR 4) AS integer)', $expression_text_sql ); + + case 'MONTH': + return sprintf( 'CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer)', $expression_text_sql ); + + case 'DAY': + return sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ); + + case 'HOUR': + $start = 12; + break; + + case 'MINUTE': + $start = 15; + break; + + case 'SECOND': + $start = 18; + break; + + default: + return sprintf( 'CAST(EXTRACT(%s FROM CAST(%s AS timestamp)) AS integer)', $unit, $expression_text_sql ); + } + + return sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(SUBSTRING(%1$s FROM %3$d FOR 2) AS integer) ELSE 0 END', + $expression_text_sql, + $date_time_text_pattern, + $start + ); + } + /** * Get token bounds for supported MySQL date/time extract forms. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 3ee4d82e0..52132de57 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -529,26 +529,19 @@ public function test_complex_select_with_mysql_offset_count_limit_is_translated_ * 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 = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_SQL_Capture_Connection(), 'wptests' ); + $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 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 ?', + 'SELECT * FROM wptests_posts LIMIT ?, ?' => 'SELECT * FROM wptests_posts LIMIT ? OFFSET ?', ); foreach ( $cases as $mysql_sql => $postgresql_sql ) { - $driver->query( $mysql_sql ); - $this->assertSame( - array( - array( - 'sql' => $postgresql_sql, - 'params' => array(), - ), - ), - $driver->get_last_postgresql_queries() + $postgresql_sql, + $this->translate_driver_query_with_private_method( $driver, 'translate_simple_mysql_select_query', $mysql_sql ) ); } } @@ -557,7 +550,9 @@ public function test_mysql_offset_count_limit_variants_are_translated_to_postgre * Tests PostgreSQL LIMIT count OFFSET offset syntax is preserved. */ public function test_existing_limit_offset_clause_is_preserved(): void { - $driver = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_SQL_Capture_Connection(), 'wptests' ); + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (ID INTEGER)' ); $select = 'SELECT * FROM wptests_posts LIMIT 10 OFFSET 5'; @@ -1116,36 +1111,42 @@ public function test_found_rows_returns_last_sql_calc_found_rows_count(): void { * Tests MySQL date/time extraction functions are translated for PostgreSQL. */ public function test_mysql_date_time_extract_functions_are_translated_to_postgresql(): void { - $driver = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection(), 'wptests' ); + $driver = $this->create_driver(); $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, DAYOFMONTH(post_date) AS d, DAY(post_date) AS day_value, HOUR(post_date) AS h, MINUTE(post_date) AS i, SECOND(post_date) AS s, EXTRACT(DAY FROM post_date) AS extracted_day FROM wptests_posts WHERE ID = 1'; - $rows = $driver->query( $select ); + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); - $this->assertCount( 1, $rows ); - $this->assertSame( '2026', $rows[0]->y ); - $this->assertSame( '6', $rows[0]->m ); - $this->assertSame( '10', $rows[0]->d ); - $this->assertSame( '10', $rows[0]->day_value ); - $this->assertSame( '14', $rows[0]->h ); - $this->assertSame( '8', $rows[0]->i ); - $this->assertSame( '9', $rows[0]->s ); - $this->assertSame( '10', $rows[0]->extracted_day ); $this->assertSame( - array( - array( - 'sql' => 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y, CAST(EXTRACT(MONTH FROM CAST(post_date AS timestamp)) AS integer) AS m, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS d, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS day_value, CAST(EXTRACT(HOUR FROM CAST(post_date AS timestamp)) AS integer) AS h, CAST(EXTRACT(MINUTE FROM CAST(post_date AS timestamp)) AS integer) AS i, CAST(EXTRACT(SECOND FROM CAST(post_date AS timestamp)) AS integer) AS s, CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) AS extracted_day FROM wptests_posts WHERE "ID" = 1', - 'params' => array(), - ), - ), - $driver->get_last_postgresql_queries() + 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ) . ' AS y, ' . $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ) . ' AS m, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS d, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS day_value, ' . $this->get_expected_zero_date_safe_extract_sql( 'HOUR', 'post_date' ) . ' AS h, ' . $this->get_expected_zero_date_safe_extract_sql( 'MINUTE', 'post_date' ) . ' AS i, ' . $this->get_expected_zero_date_safe_extract_sql( 'SECOND', 'post_date' ) . ' AS s, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS extracted_day FROM wptests_posts WHERE "ID" = 1', + $sql ); } + /** + * Tests generated date/time extraction SQL is safe for MySQL zero-date values. + */ + public function test_mysql_date_time_extract_functions_are_zero_date_safe_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, DAYOFMONTH(post_date) AS d, HOUR(post_date) AS h FROM wptests_posts WHERE post_date = \'0000-00-00 00:00:00\''; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CASE WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) = '0000'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( "THEN CASE WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 12 FOR 2) AS integer) ELSE 0 END", $sql ); + $this->assertStringNotContainsString( 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y', $sql ); + } + /** * 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 = new WP_PostgreSQL_Driver( new WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection(), 'wptests' ); + $driver = $this->create_driver(); $select = "SELECT post_id FROM wptests_postmeta, wptests_posts WHERE ID = post_id @@ -1156,16 +1157,11 @@ public function test_wordpress_date_query_extract_functions_are_translated_to_po AND MONTH(post_date) = 6 AND DAYOFMONTH(post_date) = 10"; - $driver->query( $select ); + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); $this->assertSame( - array( - array( - 'sql' => '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 CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) = 2026 AND CAST(EXTRACT(MONTH FROM CAST(post_date AS timestamp)) AS integer) = 6 AND CAST(EXTRACT(DAY FROM CAST(post_date AS timestamp)) AS integer) = 10', - 'params' => array(), - ), - ), - $driver->get_last_postgresql_queries() + '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 ); } @@ -1841,6 +1837,90 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Translate a query by calling a private driver translator. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_driver_query_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?string { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?string { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } + + /** + * Get expected zero-date-safe PostgreSQL date/time extract SQL. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = 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 + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', + $zero_date_condition, + $this->get_expected_zero_date_extract_part_sql( $unit, $expression_text_sql ), + $unit, + $expression_sql + ); + } + + /** + * Get expected PostgreSQL SQL that extracts one part from a zero-ish date string. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_extract_part_sql( string $unit, string $expression_text_sql ): string { + switch ( $unit ) { + case 'YEAR': + return sprintf( 'CAST(SUBSTRING(%s FROM 1 FOR 4) AS integer)', $expression_text_sql ); + + case 'MONTH': + return sprintf( 'CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer)', $expression_text_sql ); + + case 'DAY': + return sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ); + + case 'HOUR': + $start = 12; + break; + + case 'MINUTE': + $start = 15; + break; + + case 'SECOND': + $start = 18; + break; + + default: + throw new InvalidArgumentException( 'Unsupported test extract unit.' ); + } + + return sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM %2$d FOR 2) AS integer) ELSE 0 END', + $expression_text_sql, + $start + ); + } + /** * Check whether an injected SQLite backend table exists. * @@ -2009,65 +2089,6 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive } } -/** - * Fixture connection that records translated SELECT SQL without executing it. - */ -class WP_PostgreSQL_Driver_SQL_Capture_Connection extends WP_PostgreSQL_Connection { - /** - * Constructor. - */ - public function __construct() { - parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); - } - - /** - * Execute a query, returning an empty result for captured SELECT statements. - * - * @param string $sql SQL query. - * @param array $params Query parameters. - * @return PDOStatement Statement. - */ - public function query( string $sql, array $params = array() ): PDOStatement { - if ( 0 === strpos( $sql, 'SELECT ' ) ) { - return parent::query( 'SELECT 1 WHERE 0 = 1' ); - } - - return parent::query( $sql, $params ); - } -} - -/** - * Fixture connection that accepts PostgreSQL EXTRACT syntax in driver tests. - */ -class WP_PostgreSQL_Driver_Date_Extract_Fixture_Connection extends WP_PostgreSQL_Driver_SQL_Capture_Connection { - /** - * Execute a query, returning MySQL-compatible date/time extract values. - * - * @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, 'EXTRACT(' ) ) { - $stmt = $this->get_pdo()->prepare( - 'SELECT - 2026 AS y, - 6 AS m, - 10 AS d, - 10 AS day_value, - 14 AS h, - 8 AS i, - 9 AS s, - 10 AS extracted_day' - ); - $stmt->execute(); - return $stmt; - } - - return parent::query( $sql, $params ); - } -} - /** * Fixture connection that accepts PostgreSQL ALTER TABLE syntax in driver tests. */ From 64d0061f28d9ab2960d216b7037086084cb92adb Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 11:28:51 +0000 Subject: [PATCH 046/142] Guard PostgreSQL zero-date literal casts --- .../postgresql/class-wp-postgresql-driver.php | 13 ++- .../tests/WP_PostgreSQL_Driver_Tests.php | 105 +++++++++++++++++- 2 files changed, 111 insertions(+), 7 deletions(-) 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 index 17f1845da..3865f7ea3 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4502,20 +4502,25 @@ private function translate_mysql_date_time_extract_to_postgresql( array $tokens, * @return string PostgreSQL expression SQL. */ private function get_postgresql_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { - $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); - $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; - $zero_date_condition = sprintf( + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; + $zero_date_condition = 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 ); + $timestamp_expression_sql = sprintf( + 'CASE WHEN %1$s THEN NULL ELSE %2$s END', + $zero_date_condition, + $expression_text_sql + ); return sprintf( 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', $zero_date_condition, $this->get_postgresql_zero_date_extract_part_sql( $unit, $expression_text_sql ), $unit, - $expression_sql + $timestamp_expression_sql ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 52132de57..cc0ef51ab 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1140,6 +1140,100 @@ public function test_mysql_date_time_extract_functions_are_zero_date_safe_for_po $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) AS integer)', $sql ); $this->assertStringContainsString( "THEN CASE WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 12 FOR 2) AS integer) ELSE 0 END", $sql ); $this->assertStringNotContainsString( 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y', $sql ); + $this->assertStringContainsString( 'CAST(EXTRACT(YEAR FROM CAST(CASE WHEN CAST(post_date AS text)', $sql ); + $this->assertStringContainsString( 'THEN NULL ELSE CAST(post_date AS text) END AS timestamp)', $sql ); + } + + /** + * Tests literal MySQL zero-date extraction SQL guards the timestamp cast. + */ + public function test_mysql_date_time_extract_functions_guard_literal_zero_dates_for_postgresql(): void { + $driver = $this->create_driver(); + + $literals = array( + '0000-00-00 00:00:00', + '0000-00-00', + '2020-00-15 00:00:00', + '2020-01-00 00:00:00', + '2026-06-10 14:08:09', + ); + $extract_functions = array( + array( + 'name' => 'YEAR', + 'unit' => 'YEAR', + ), + array( + 'name' => 'MONTH', + 'unit' => 'MONTH', + ), + array( + 'name' => 'DAYOFMONTH', + 'unit' => 'DAY', + ), + array( + 'name' => 'DAY', + 'unit' => 'DAY', + ), + array( + 'name' => 'HOUR', + 'unit' => 'HOUR', + ), + array( + 'name' => 'MINUTE', + 'unit' => 'MINUTE', + ), + array( + 'name' => 'SECOND', + 'unit' => 'SECOND', + ), + ); + + foreach ( $literals as $literal ) { + $expression_sql = "'" . $literal . "'"; + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + foreach ( $extract_functions as $extract_function ) { + $function_sql = sprintf( '%s(%s)', $extract_function['name'], $expression_sql ); + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ' . $function_sql . ' AS extracted_value' + ); + $expected_sql = 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( $extract_function['unit'], $expression_sql ) . ' AS extracted_value'; + + $this->assertSame( + $expected_sql, + $sql, + $function_sql + ); + $this->assertStringContainsString( + 'CAST(EXTRACT(' . $extract_function['unit'] . ' FROM CAST(CASE WHEN ' . $expression_text_sql, + $sql, + $function_sql + ); + $this->assertStringContainsString( + 'THEN NULL ELSE ' . $expression_text_sql . ' END AS timestamp)', + $sql, + $function_sql + ); + $this->assertStringNotContainsString( + 'CAST(EXTRACT(' . $extract_function['unit'] . ' FROM CAST(' . $expression_sql . ' AS timestamp))', + $sql, + $function_sql + ); + } + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT EXTRACT(DAY FROM '2020-01-00 00:00:00') AS extracted_value" + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', "'2020-01-00 00:00:00'" ) . ' AS extracted_value', + $sql + ); } /** @@ -1865,18 +1959,23 @@ function ( string $bound_method_name, string $bound_query ): ?string { * @return string PostgreSQL expression SQL. */ private function get_expected_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { - $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); - $zero_date_condition = sprintf( + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = 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 ); + $timestamp_expression_sql = sprintf( + 'CASE WHEN %1$s THEN NULL ELSE %2$s END', + $zero_date_condition, + $expression_text_sql + ); return sprintf( 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', $zero_date_condition, $this->get_expected_zero_date_extract_part_sql( $unit, $expression_text_sql ), $unit, - $expression_sql + $timestamp_expression_sql ); } From b1252f5106b42c0fdb12f5ede19f0befb43ee7a4 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 12:17:33 +0000 Subject: [PATCH 047/142] Translate PostgreSQL expression operators --- .../postgresql/class-wp-postgresql-driver.php | 374 ++++++++++++++++++ .../WP_PostgreSQL_Driver_RegExp_Tests.php | 110 ++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 161 ++++++++ 3 files changed, 645 insertions(+) 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 index 3865f7ea3..6ea0888b8 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4383,6 +4383,18 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in $token = $tokens[ $i ]; $fragment_token_id = $token->id; $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_field_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_integer_cast_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_rand_function_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); } @@ -4462,6 +4474,352 @@ private function get_mysql_limit_offset_count_bounds( array $tokens, int $positi ); } + /** + * Translate MySQL FIELD(expr, value, ...) to a PostgreSQL CASE expression. + * + * PostgreSQL does not coerce unknown text and integer values the same way + * MySQL FIELD() does. Cast both sides of each comparison to text to keep the + * WordPress ordering use-cases executable across mixed ID/name arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_field_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'field' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || count( $arguments ) < 2 ) { + return null; + } + + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + + $clauses = array( + sprintf( 'WHEN %s IS NULL THEN 0', $value_sql ), + ); + + for ( $i = 1; $i < count( $arguments ); $i++ ) { + $argument_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[ $i ]['start'], + $arguments[ $i ]['end'] + ); + + $clauses[] = sprintf( + 'WHEN CAST(%1$s AS text) = CAST(%2$s AS text) THEN %3$d', + $value_sql, + $argument_sql, + $i + ); + } + + return array( + 'sql' => 'CASE ' . implode( ' ', $clauses ) . ' ELSE 0 END', + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CAST(expr AS SIGNED/UNSIGNED [INTEGER]) to PostgreSQL. + * + * Both SIGNED and UNSIGNED map to bigint. This preserves WordPress meta + * comparison/query execution but does not emulate MySQL UNSIGNED wraparound. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_integer_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS bigint)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL integer CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_integer_cast_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || null === $this->get_postgresql_integer_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Get the PostgreSQL type for supported MySQL integer cast type tokens. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return string|null PostgreSQL type SQL, or null when unsupported. + */ + private function get_postgresql_integer_cast_type( array $tokens, int $start, int $end ): ?string { + if ( + ! isset( $tokens[ $start ] ) + || ! in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::SIGNED_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), + true + ) + ) { + return null; + } + + if ( $start + 1 === $end ) { + return 'bigint'; + } + + if ( + $start + 2 === $end + && isset( $tokens[ $start + 1 ] ) + && in_array( + $tokens[ $start + 1 ]->id, + array( + WP_MySQL_Lexer::INT_SYMBOL, + WP_MySQL_Lexer::INTEGER_SYMBOL, + ), + true + ) + ) { + return 'bigint'; + } + + return null; + } + + /** + * Translate MySQL REGEXP/RLIKE operators to PostgreSQL regex operators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Operator token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_regexp_operator_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position ]->id + && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 1, $end ) + ) { + return array( + 'sql' => '~', + 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, + 'position' => $position, + ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position + 1 ]->id + && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 2, $end ) + ) { + return array( + 'sql' => '!~', + 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, + 'position' => $position + 1, + ); + } + + return null; + } + + /** + * Check whether a REGEXP predicate starts with the unsupported BINARY modifier. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First right-hand predicate token. + * @param int $end Final token position, exclusive. + * @return bool Whether the predicate uses REGEXP BINARY/RLIKE BINARY. + */ + private function is_mysql_regexp_binary_predicate( array $tokens, int $position, int $end ): bool { + return $position < $end + && isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $position ]->id; + } + + /** + * Translate MySQL RAND() and RAND(seed) calls to PostgreSQL random(). + * + * PostgreSQL setseed() is session-stateful, so RAND(seed) deliberately maps + * to random() for now instead of leaking deterministic seed state into later + * statements. Seeded deterministic ordering is left for a higher-fidelity + * emulation path. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_rand_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'rand' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || count( $arguments ) > 1 ) { + return null; + } + + return array( + 'sql' => 'random()', + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a MySQL identifier function call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @param string $function_name Lowercase function name to match. + * @return array{arguments_start: int, arguments_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_function_call_bounds( array $tokens, int $position, int $end, string $function_name ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::IDENTIFIER !== $tokens[ $position ]->id + || strtolower( $tokens[ $position ]->get_value() ) !== $function_name + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + return array( + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Split a bounded token range into top-level comma-separated arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token position. + * @param int $end Final argument token position, exclusive. + * @return array|null Argument bounds, or null when malformed. + */ + private function split_top_level_mysql_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start === $end ) { + return array(); + } + + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $i ]->id ) { + if ( $argument_start === $i ) { + return null; + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $i, + ); + $argument_start = $i + 1; + } + } + + if ( 0 !== $depth || $argument_start === $end ) { + return null; + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $end, + ); + + return $arguments; + } + /** * Translate supported MySQL date/time extract functions to PostgreSQL. * @@ -4943,6 +5301,22 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'field' ) ) { + return true; + } + + if ( null !== $this->get_mysql_integer_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'rand' ) ) { + return true; + } + if ( null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $i, $end ) ) { return true; } 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 index 9c44e3f8d..ef34f9674 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -41,4 +41,114 @@ static function ( string $sql, array $params ) use ( &$logged_queries ): void { end( $logged_queries ) ); } + + /** + * Tests REGEXP, RLIKE, and NOT REGEXP predicates use PostgreSQL regex operators. + */ + public function test_regexp_predicates_are_translated_to_postgresql_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'" + ) + ); + } + + /** + * 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 unsupported REGEXP BINARY predicates fall through visibly. + */ + public function test_regexp_binary_predicate_is_not_silently_remapped(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key REGEXP BINARY '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key REGEXP BINARY '^foo'" + ) + ); + } + + /** + * Creates a PostgreSQL driver backed by an injected in-memory PDO. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Translate a query by calling a private driver translator. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_driver_query_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?string { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?string { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index cc0ef51ab..16e4c8fba 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1019,6 +1019,167 @@ public function test_convert_using_right_hand_compound_expression_preserves_grou ); } + /** + * 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 MySQL SIGNED and UNSIGNED casts are translated to PostgreSQL bigint casts. + */ + public function test_signed_and_unsigned_casts_are_translated_to_postgresql_bigint(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_postmeta (meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'10\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'2\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'-1\')' ); + + $select = 'SELECT meta_value FROM wptests_postmeta WHERE CAST(meta_value AS SIGNED) > 0 ORDER BY CAST(meta_value AS UNSIGNED INTEGER) DESC'; + $rows = $driver->query( $select ); + + $this->assertSame( '10', $rows[0]->meta_value ); + $this->assertSame( '2', $rows[1]->meta_value ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT meta_value FROM wptests_postmeta WHERE CAST(meta_value AS bigint) > 0 ORDER BY CAST(meta_value AS bigint) DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests lowercase signed integer casts trigger PostgreSQL compatibility translation. + */ + public function test_lowercase_signed_integer_cast_triggers_postgresql_rewrite(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT cast('7' as signed integer) AS cast_value" ); + + $this->assertSame( '7', $rows[0]->cast_value ); + $this->assertSame( + array( + array( + 'sql' => "SELECT CAST('7' AS bigint) AS cast_value", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests RAND() and RAND(seed) are translated without mutating session seed state. + */ + public function test_rand_functions_are_translated_to_postgresql_random(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_links (link_id INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (2)' ); + + $rows = $driver->query( 'SELECT link_id FROM wptests_links ORDER BY RAND(7) LIMIT 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT link_id FROM wptests_links ORDER BY random() LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'SELECT rand() AS random_value' ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT random() AS random_value', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL-only expression names inside string literals are not rewritten. + */ + public function test_expression_rewrite_does_not_replace_string_literals(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', '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)', 'RAND()' AS literal_value", + $sql + ); + } + /** * Tests SELECT DISTINCT term ID queries include ORDER BY expressions. */ From c8aa9a6cf600d68b29a5ddcbf9c2450228686fac Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 12:35:47 +0000 Subject: [PATCH 048/142] Fix PostgreSQL CAST and REGEXP emulation --- .../postgresql/class-wp-postgresql-driver.php | 29 +++- .../WP_PostgreSQL_Driver_RegExp_Tests.php | 46 +++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 138 ++++++++++++++++-- 3 files changed, 187 insertions(+), 26 deletions(-) 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 index 6ea0888b8..8ad98b9ff 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -2968,7 +2968,7 @@ private function translate_wordpress_options_regexp_delete_query( string $query } return sprintf( - 'DELETE FROM %s WHERE %s ~ %s', + 'DELETE FROM %s WHERE %s ~* %s', $this->connection->quote_identifier( $table_name ), $this->connection->quote_identifier( $column ), $this->connection->quote( $tokens[6]->get_value() ) @@ -4553,12 +4553,33 @@ private function translate_mysql_integer_cast_to_postgresql( array $tokens, int ); return array( - 'sql' => sprintf( 'CAST(%s AS bigint)', $expression_sql ), + 'sql' => $this->get_postgresql_mysql_integer_cast_sql( $expression_sql ), 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, 'position' => $bounds['close'], ); } + /** + * Get PostgreSQL SQL for MySQL-compatible integer text coercion. + * + * MySQL accepts text values when casting to SIGNED/UNSIGNED and coerces the + * leading integer prefix, or zero when no prefix exists. PostgreSQL bigint + * casts reject those values, so extract a safe prefix before casting. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_integer_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $integer_pattern = $this->connection->quote( '^[[:space:]]*[+-]?[0-9]+' ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(%1$s, %2$s), \'0\') AS bigint) END', + $expression_text_sql, + $integer_pattern + ); + } + /** * Get token bounds for a supported MySQL integer CAST expression. * @@ -4666,7 +4687,7 @@ private function translate_mysql_regexp_operator_to_postgresql( array $tokens, i && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 1, $end ) ) { return array( - 'sql' => '~', + 'sql' => '~*', 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, 'position' => $position, ); @@ -4679,7 +4700,7 @@ private function translate_mysql_regexp_operator_to_postgresql( array $tokens, i && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 2, $end ) ) { return array( - 'sql' => '!~', + 'sql' => '!~*', 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, 'position' => $position + 1, ); 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 index ef34f9674..dc54d3c6d 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -35,7 +35,7 @@ static function ( string $sql, array $params ) use ( &$logged_queries ): void { $this->assertSame( array(), $driver->get_last_postgresql_queries() ); $this->assertSame( array( - 'sql' => 'DELETE FROM "wptests_options" WHERE "option_name" ~ \'^_transient_feed_\'', + 'sql' => 'DELETE FROM "wptests_options" WHERE "option_name" ~* \'^_transient_feed_\'', 'params' => array(), ), end( $logged_queries ) @@ -43,13 +43,13 @@ static function ( string $sql, array $params ) use ( &$logged_queries ): void { } /** - * Tests REGEXP, RLIKE, and NOT REGEXP predicates use PostgreSQL regex operators. + * Tests REGEXP, RLIKE, and NOT REGEXP predicates use case-insensitive PostgreSQL regex operators. */ - public function test_regexp_predicates_are_translated_to_postgresql_regex_operators(): void { + 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'", + "SELECT * FROM wptests_postmeta WHERE meta_key ~* '^foo'", $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', @@ -57,7 +57,7 @@ public function test_regexp_predicates_are_translated_to_postgresql_regex_operat ) ); $this->assertSame( - "SELECT * FROM wptests_postmeta WHERE meta_key !~ '^foo'", + "SELECT * FROM wptests_postmeta WHERE meta_key !~* '^foo'", $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', @@ -65,7 +65,7 @@ public function test_regexp_predicates_are_translated_to_postgresql_regex_operat ) ); $this->assertSame( - "SELECT * FROM wptests_postmeta WHERE meta_key ~ '^foo'", + "SELECT * FROM wptests_postmeta WHERE meta_key ~* '^foo'", $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', @@ -74,6 +74,38 @@ public function test_regexp_predicates_are_translated_to_postgresql_regex_operat ); } + /** + * 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. */ @@ -81,7 +113,7 @@ public function test_lowercase_rlike_predicate_with_qualified_identifier_is_tran $driver = $this->create_driver(); $this->assertSame( - "SELECT * FROM wptests_posts WHERE wptests_posts.\"ID\" ~ '^[0-9]+$'", + "SELECT * FROM wptests_posts WHERE wptests_posts.\"ID\" ~* '^[0-9]+$'", $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_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 index 16e4c8fba..92f796ca8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1084,25 +1084,87 @@ public function test_field_function_returns_zero_for_null_and_missing_values(): } /** - * Tests MySQL SIGNED and UNSIGNED casts are translated to PostgreSQL bigint casts. + * Tests MySQL SIGNED and UNSIGNED casts coerce text safely for PostgreSQL. */ - public function test_signed_and_unsigned_casts_are_translated_to_postgresql_bigint(): void { - $driver = $this->create_driver(); + public function test_signed_and_unsigned_casts_coerce_mysql_text_values_for_postgresql(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); - $driver->query( 'CREATE TABLE wptests_postmeta (meta_value TEXT NOT NULL)' ); - $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'10\')' ); - $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'2\')' ); - $driver->query( 'INSERT INTO wptests_postmeta (meta_value) VALUES (\'-1\')' ); + $values = array( + array( 1, '' ), + array( 2, ' ' ), + array( 3, 'abc' ), + array( 4, '10abc' ), + array( 5, '-7xyz' ), + array( 6, '+8' ), + array( 7, '42' ), + array( 8, '+' ), + array( 9, '-' ), + array( 10, ' 15xyz' ), + ); + + foreach ( $values as $value ) { + $driver->query( + sprintf( + 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (%d, \'score\', %s)', + $value[0], + $driver->get_connection()->quote( $value[1] ) + ) + ); + } - $select = 'SELECT meta_value FROM wptests_postmeta WHERE CAST(meta_value AS SIGNED) > 0 ORDER BY CAST(meta_value AS UNSIGNED INTEGER) DESC'; + $select = 'SELECT post_id, meta_value, CAST(meta_value AS SIGNED) AS signed_value, CAST(meta_value AS UNSIGNED) AS unsigned_value FROM wptests_postmeta ORDER BY post_id'; $rows = $driver->query( $select ); - $this->assertSame( '10', $rows[0]->meta_value ); - $this->assertSame( '2', $rows[1]->meta_value ); + $this->assertSame( + array( + array( '', '0', '0' ), + array( ' ', '0', '0' ), + array( 'abc', '0', '0' ), + array( '10abc', '10', '10' ), + array( '-7xyz', '-7', '-7' ), + array( '+8', '8', '8' ), + array( '42', '42', '42' ), + array( '+', '0', '0' ), + array( '-', '0', '0' ), + array( ' 15xyz', '15', '15' ), + ), + array_map( + static function ( $row ): array { + return array( $row->meta_value, $row->signed_value, $row->unsigned_value ); + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); $this->assertSame( array( array( - 'sql' => 'SELECT meta_value FROM wptests_postmeta WHERE CAST(meta_value AS bigint) > 0 ORDER BY CAST(meta_value AS bigint) DESC', + 'sql' => 'SELECT post_id, meta_value, ' . $meta_value_cast_sql . ' AS signed_value, ' . $meta_value_cast_sql . ' AS unsigned_value FROM wptests_postmeta ORDER BY post_id', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $select = "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = 'score' AND CAST(meta_value AS SIGNED) > 0 ORDER BY CAST(meta_value AS UNSIGNED INTEGER) DESC, post_id ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '42', ' 15xyz', '10abc', '+8' ), + array_map( + static function ( $row ): string { + return $row->meta_value; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = 'score' AND " . $meta_value_cast_sql . ' > 0 ORDER BY ' . $meta_value_cast_sql . ' DESC, post_id ASC', 'params' => array(), ), ), @@ -1114,7 +1176,7 @@ public function test_signed_and_unsigned_casts_are_translated_to_postgresql_bigi * Tests lowercase signed integer casts trigger PostgreSQL compatibility translation. */ public function test_lowercase_signed_integer_cast_triggers_postgresql_rewrite(): void { - $driver = $this->create_driver(); + $driver = $this->create_driver_with_postgresql_substring_function(); $rows = $driver->query( "SELECT cast('7' as signed integer) AS cast_value" ); @@ -1122,7 +1184,7 @@ public function test_lowercase_signed_integer_cast_triggers_postgresql_rewrite() $this->assertSame( array( array( - 'sql' => "SELECT CAST('7' AS bigint) AS cast_value", + 'sql' => 'SELECT ' . $this->get_expected_mysql_integer_cast_sql( "'7'" ) . ' AS cast_value', 'params' => array(), ), ), @@ -1171,11 +1233,11 @@ public function test_rand_functions_are_translated_to_postgresql_random(): void 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)', 'RAND()' AS literal_value"; + $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)', 'RAND()' AS literal_value", + "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', 'CAST(meta_value AS UNSIGNED)', 'RAND()' AS literal_value", $sql ); } @@ -2092,6 +2154,37 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Creates a PostgreSQL driver with a SQLite shim for SUBSTRING(text, pattern). + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver_with_postgresql_substring_function(): WP_PostgreSQL_Driver { + $pdo_class = class_exists( 'Pdo\Sqlite' ) ? 'Pdo\Sqlite' : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); + $substring = static function ( $value, $pattern ): ?string { + if ( null === $value ) { + return null; + } + + $php_pattern = '/' . str_replace( '/', '\\/', (string) $pattern ) . '/'; + if ( 1 === preg_match( $php_pattern, (string) $value, $matches ) ) { + return $matches[0]; + } + + return null; + }; + + if ( method_exists( $pdo, 'createFunction' ) ) { + $pdo->createFunction( 'SUBSTRING', $substring, 2 ); + } else { + $pdo->sqliteCreateFunction( 'SUBSTRING', $substring, 2 ); + } + + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + /** * Translate a query by calling a private driver translator. * @@ -2112,6 +2205,21 @@ function ( string $bound_method_name, string $bound_query ): ?string { return $translator( $method_name, $query ); } + /** + * 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 zero-date-safe PostgreSQL date/time extract SQL. * From a43b5d0762c13252b40eb2741b4653236bc8c1f5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 12:43:35 +0000 Subject: [PATCH 049/142] Align PostgreSQL test assignments --- .../mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 92f796ca8..20fa26578 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2160,9 +2160,9 @@ private function create_driver(): WP_PostgreSQL_Driver { * @return WP_PostgreSQL_Driver */ private function create_driver_with_postgresql_substring_function(): WP_PostgreSQL_Driver { - $pdo_class = class_exists( 'Pdo\Sqlite' ) ? 'Pdo\Sqlite' : PDO::class; - $pdo = new $pdo_class( 'sqlite::memory:' ); - $substring = static function ( $value, $pattern ): ?string { + $pdo_class = class_exists( 'Pdo\Sqlite' ) ? 'Pdo\Sqlite' : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); + $substring = static function ( $value, $pattern ): ?string { if ( null === $value ) { return null; } From e79d952931f4cc771804ce3e37e7a2f99ee0d82b Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 13:36:05 +0000 Subject: [PATCH 050/142] Fix PostgreSQL distinct ordering shapes --- .../postgresql/class-wp-postgresql-driver.php | 719 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 164 +++- 2 files changed, 840 insertions(+), 43 deletions(-) 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 index 8ad98b9ff..1117ee7e4 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -418,6 +418,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -430,12 +432,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } - $is_sql_calc_found_rows_query = false; - $translated_query = $this->translate_sql_calc_found_rows_select_query( $query ); + $translated_query = $this->translate_sql_calc_found_rows_select_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $is_sql_calc_found_rows_query = true; - $translated_for_postgresql = true; + $query = $translated_query; + $translated_for_postgresql = true; } if ( ! $translated_for_postgresql ) { @@ -3585,30 +3585,66 @@ private function translate_simple_mysql_select_query( string $query ): ?string { * Translate SELECT DISTINCT queries whose ORDER BY expression is not selected. * * PostgreSQL requires ORDER BY expressions in SELECT DISTINCT statements to - * appear in the projection. WordPress term queries commonly select only - * term_id while ordering by t.name; adding the order expression preserves the - * first result column used by wpdb::get_col(). + * appear in the projection. Grouping by the visible projection and ordering + * by a hidden aggregate keeps the MySQL-visible result shape and avoids + * changing DISTINCT cardinality. * * @param string $query MySQL query. * @return string|null PostgreSQL query, or null when the query is unsupported. */ private function translate_distinct_order_by_query( string $query ): ?string { $tokens = $this->get_mysql_tokens( $query ); - if ( - ! isset( $tokens[0], $tokens[1] ) - || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id - || WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[1]->id - ) { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { return null; } - $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + $position = 1; + if ( WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $has_sql_calc_found_rows = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $position ]->id ) { + $has_sql_calc_found_rows = true; + ++$position; + } + + $projection_start = $position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); if ( null === $statement_end ) { return null; } - $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $statement_end ); - $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::LIMIT_SYMBOL, + $projection_start, + $statement_end + ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $projection_start, + $select_end + ); + if ( null === $order_position ) { + return $has_sql_calc_found_rows + ? 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $statement_end ) + : null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $order_position + ); if ( null === $from_position || null === $order_position @@ -3619,50 +3655,655 @@ private function translate_distinct_order_by_query( string $query ): ?string { return null; } - $order_end = $order_position + 3; if ( - isset( $tokens[ $order_position + 3 ], $tokens[ $order_position + 4 ] ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $order_position + 3 ]->id - && null !== $this->get_mysql_identifier_token_value( $tokens[ $order_position + 4 ] ) + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + if ( + $this->contains_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::AVG_SYMBOL, + WP_MySQL_Lexer::COUNT_SYMBOL, + WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL, + WP_MySQL_Lexer::MAX_SYMBOL, + WP_MySQL_Lexer::MIN_SYMBOL, + WP_MySQL_Lexer::SUM_SYMBOL, + ) + ) ) { - $order_end = $order_position + 5; + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $order_position + 2, + $select_end, + $projection_items + ); + if ( null === $order_items ) { + return null; + } + + $has_hidden_order_expression = false; + foreach ( $order_items as $order_item ) { + if ( null === $order_item['projection_index'] ) { + $has_hidden_order_expression = true; + break; + } + } + + if ( ! $has_hidden_order_expression ) { + return $has_sql_calc_found_rows + ? 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $statement_end ) + : null; + } + + return $this->build_distinct_order_by_grouped_query( + $tokens, + $projection_items, + $order_items, + $from_position, + $order_position, + $limit_position, + $statement_end + ); + } + + /** + * Parse SELECT projection items with expression bounds and visible aliases. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return array|null Projection items. + */ + private function parse_mysql_select_projection_items( array $tokens, int $start, int $end ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { + return null; + } + + $items = array(); + $alias_lookup = array(); + foreach ( $ranges as $range ) { + $item = $this->parse_mysql_select_projection_item( $tokens, $range['start'], $range['end'] ); + if ( null === $item ) { + return null; + } + + $alias_key = strtolower( $item['alias'] ); + if ( isset( $alias_lookup[ $alias_key ] ) ) { + return null; + } + + $alias_lookup[ $alias_key ] = true; + $items[] = $item; + } + + return $items; + } + + /** + * Parse one SELECT projection item. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{expression_start: int, expression_end: int, sql: string, alias: string}|null Projection item. + */ + private function parse_mysql_select_projection_item( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_start = $start; + $expression_end = $end; + $alias = null; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $expression_end = $as_position; + } else { + $implicit_alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + $expression_end = $end - 1; + } + } + + if ( $expression_start >= $expression_end ) { + return null; + } + + if ( null === $alias ) { + $alias = $this->get_mysql_select_expression_default_output_name( $tokens, $expression_start, $expression_end ); + if ( null === $alias ) { + return null; + } + } + + return array( + 'expression_start' => $expression_start, + 'expression_end' => $expression_end, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ), + 'alias' => $alias, + ); + } + + /** + * Get an explicit projection alias token value. + * + * MySQL permits string-literal aliases in projection context. Keep that + * context local so predicate string literals continue to render as values. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Alias value, or null when unsupported. + */ + private function get_mysql_projection_alias_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + + $value = $token->get_value(); + if ( $this->is_mysql_unquoted_projection_alias_value( $value ) ) { + return $value; } - if ( $order_end > $statement_end || $order_position + 2 === $order_end ) { + return null; + } + + /** + * Check whether a token value is safe as an unquoted MySQL projection alias. + * + * @param string $value Token value. + * @return bool Whether the value is identifier-shaped. + */ + private function is_mysql_unquoted_projection_alias_value( string $value ): bool { + if ( '' === $value ) { + return false; + } + + $first_character = $value[0]; + if ( '_' !== $first_character && ! ctype_alpha( $first_character ) ) { + return false; + } + + for ( $i = 1, $length = strlen( $value ); $i < $length; $i++ ) { + $character = $value[ $i ]; + if ( '_' !== $character && '$' !== $character && ! ctype_alnum( $character ) ) { + return false; + } + } + + return true; + } + + /** + * Get an implicit projection alias when a complex expression is followed by a name. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return string|null Alias value, or null when absent. + */ + private function get_mysql_implicit_projection_alias( array $tokens, int $start, int $end ): ?string { + if ( $start + 1 >= $end ) { + return null; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $end - 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + if ( isset( $tokens[ $end - 2 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $end - 2 ]->id ) { return null; } - $after_order_expression = $order_end; + return $alias; + } + + /** + * Infer the default visible name for a projected expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return string|null Output column name, or null when unsupported. + */ + private function get_mysql_select_expression_default_output_name( array $tokens, int $start, int $end ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $start + 1 === $end ) { + return $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + } + if ( - isset( $tokens[ $after_order_expression ] ) - && $after_order_expression < $statement_end - && ( - WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $after_order_expression ]->id - || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $after_order_expression ]->id - ) + $start + 3 <= $end + && isset( $tokens[ $end - 2 ], $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $end - 2 ]->id ) { - ++$after_order_expression; + return $this->get_mysql_identifier_token_value( $tokens[ $end - 1 ] ); } - if ( $after_order_expression !== $statement_end ) { + return null; + } + + /** + * Parse ORDER BY items and connect them to projected expressions when possible. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY token position, exclusive. + * @param array $projection_items Parsed projection items. + * @return array|null ORDER BY items. + */ + private function parse_mysql_select_order_by_items( array $tokens, int $start, int $end, array $projection_items ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { return null; } - $order_expression = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $order_position + 2, $order_end ); - $projection = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $from_position ); + $items = array(); + foreach ( $ranges as $range ) { + $expression_end = $range['end']; + $direction = 'ASC'; + + 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'; + --$expression_end; + } + + if ( $range['start'] >= $expression_end ) { + return null; + } + + $items[] = array( + 'expression_start' => $range['start'], + 'expression_end' => $expression_end, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $range['start'], + $expression_end + ), + 'direction' => $direction, + 'projection_index' => $this->find_mysql_projection_for_order_expression( + $tokens, + $range['start'], + $expression_end, + $projection_items + ), + ); + } + + return $items; + } + + /** + * Find a projection item that satisfies an ORDER BY expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY expression token. + * @param int $end Final ORDER BY expression token, exclusive. + * @param array $projection_items Parsed projection items. + * @return int|null Projection item index, or null when not projected. + */ + private function find_mysql_projection_for_order_expression( array $tokens, int $start, int $end, array $projection_items ): ?int { + foreach ( $projection_items as $index => $projection_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $start, + $end, + $projection_item['expression_start'], + $projection_item['expression_end'] + ) + ) { + return $index; + } + } + + if ( $start + 1 === $end ) { + $ordinal = $this->get_mysql_order_by_ordinal_projection_index( $tokens[ $start ], count( $projection_items ) ); + if ( null !== $ordinal ) { + return $ordinal; + } + + $alias = $this->get_mysql_identifier_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; + } - if ( false !== stripos( $projection, $order_expression ) ) { + /** + * Resolve a positional ORDER BY item to a projection index. + * + * @param WP_MySQL_Token $token ORDER BY token. + * @param int $projection_count Number of projected columns. + * @return int|null Zero-based projection index, or null when unsupported. + */ + private function get_mysql_order_by_ordinal_projection_index( WP_MySQL_Token $token, int $projection_count ): ?int { + if ( + ! in_array( $token->id, array( WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LONG_NUMBER ), true ) + || ! ctype_digit( $token->get_value() ) + ) { return null; } - return sprintf( - 'SELECT DISTINCT %s, %s %s', - $projection, - $order_expression, - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + $ordinal = (int) $token->get_value(); + if ( $ordinal < 1 || $ordinal > $projection_count ) { + return null; + } + + return $ordinal - 1; + } + + /** + * Check whether a bounded token range contains any token IDs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @param int[] $token_ids Token IDs to detect. + * @return bool Whether any token ID was found. + */ + private function contains_mysql_token( array $tokens, int $start, int $end, array $token_ids ): bool { + $lookup = array(); + foreach ( $token_ids as $token_id ) { + $lookup[ $token_id ] = true; + } + + for ( $i = $start; $i < $end; $i++ ) { + if ( isset( $lookup[ $tokens[ $i ]->id ] ) ) { + return true; + } + } + + return false; + } + + /** + * Build a grouped derived-table rewrite for DISTINCT ORDER BY queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @return string PostgreSQL query. + */ + private function build_distinct_order_by_grouped_query( + array $tokens, + array $projection_items, + array $order_items, + int $from_position, + int $order_position, + ?int $limit_position, + int $statement_end + ): string { + $derived_table_alias = '__wp_pg_distinct'; + $quoted_derived_table_alias = $this->connection->quote_identifier( $derived_table_alias ); + $inner_projection_sql = array(); + $outer_projection_sql = array(); + $group_by_sql = array(); + + foreach ( $projection_items as $projection_item ) { + $quoted_alias = $this->connection->quote_identifier( $projection_item['alias'] ); + $inner_projection_sql[] = $projection_item['sql'] . ' AS ' . $quoted_alias; + $outer_projection_sql[] = sprintf( + '%s.%s AS %s', + $quoted_derived_table_alias, + $quoted_alias, + $quoted_alias + ); + $group_by_sql[] = $projection_item['sql']; + } + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + continue; + } + + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + $quoted_order_alias = $this->connection->quote_identifier( $this->get_distinct_order_by_hidden_alias( $index ) ); + $inner_projection_sql[] = sprintf( + '%s(%s) AS %s', + $aggregate_function, + $order_item['sql'], + $quoted_order_alias + ); + } + + $sql = sprintf( + 'SELECT %s FROM (SELECT %s %s GROUP BY %s) AS %s ORDER BY %s', + implode( ', ', $outer_projection_sql ), + implode( ', ', $inner_projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $order_position ), + implode( ', ', $group_by_sql ), + $quoted_derived_table_alias, + $this->get_distinct_order_by_outer_order_sql( $projection_items, $order_items, $quoted_derived_table_alias ) + ); + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Get the hidden ORDER BY alias for a parsed order item. + * + * @param int $index ORDER BY item index. + * @return string Hidden alias. + */ + private function get_distinct_order_by_hidden_alias( int $index ): string { + return '__wp_pg_order_' . $index; + } + + /** + * Build the outer ORDER BY clause for a grouped DISTINCT rewrite. + * + * @param array $projection_items Parsed projection items. + * @param array $order_items Parsed ORDER BY items. + * @param string $quoted_derived_table_alias Quoted derived table alias. + * @return string Outer ORDER BY SQL. + */ + private function get_distinct_order_by_outer_order_sql( array $projection_items, array $order_items, string $quoted_derived_table_alias ): string { + $order_sql = array(); + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + $order_alias = $projection_items[ $order_item['projection_index'] ]['alias']; + } else { + $order_alias = $this->get_distinct_order_by_hidden_alias( $index ); + } + + $order_sql[] = sprintf( + '%s.%s %s', + $quoted_derived_table_alias, + $this->connection->quote_identifier( $order_alias ), + $order_item['direction'] + ); + } + + return implode( ', ', $order_sql ); + } + + /** + * Check whether two expression token ranges are structurally equivalent. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $left_start First left expression token. + * @param int $left_end Final left expression token, exclusive. + * @param int $right_start First right expression token. + * @param int $right_end Final right expression token, exclusive. + * @return bool Whether the token ranges are equivalent. + */ + private function are_mysql_token_ranges_equivalent( + array $tokens, + int $left_start, + int $left_end, + int $right_start, + int $right_end + ): bool { + $left_bounds = $this->normalize_mysql_expression_bounds( $tokens, $left_start, $left_end ); + $right_bounds = $this->normalize_mysql_expression_bounds( $tokens, $right_start, $right_end ); + + $left_start = $left_bounds['start']; + $left_end = $left_bounds['end']; + $right_start = $right_bounds['start']; + $right_end = $right_bounds['end']; + + if ( $left_end - $left_start !== $right_end - $right_start ) { + return false; + } + + for ( $left = $left_start, $right = $right_start; $left < $left_end; $left++, $right++ ) { + if ( ! $this->are_mysql_tokens_equivalent( $tokens[ $left ], $tokens[ $right ] ) ) { + return false; + } + } + + return true; + } + + /** + * Normalize expression bounds by removing full-range wrapper parentheses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return array{start: int, end: int} Normalized bounds. + */ + private function normalize_mysql_expression_bounds( array $tokens, int $start, int $end ): array { + while ( + $start + 2 <= $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ) === $end + ) { + ++$start; + --$end; + } + + return array( + 'start' => $start, + 'end' => $end, ); } + /** + * Check whether two individual MySQL tokens are structurally equivalent. + * + * @param WP_MySQL_Token $left Left token. + * @param WP_MySQL_Token $right Right token. + * @return bool Whether the tokens are equivalent. + */ + private function are_mysql_tokens_equivalent( WP_MySQL_Token $left, WP_MySQL_Token $right ): bool { + $left_identifier = $this->get_mysql_identifier_token_value( $left ); + $right_identifier = $this->get_mysql_identifier_token_value( $right ); + if ( null !== $left_identifier || null !== $right_identifier ) { + return null !== $left_identifier + && null !== $right_identifier + && strtolower( $left_identifier ) === strtolower( $right_identifier ); + } + + if ( $left->id !== $right->id ) { + return false; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $left->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $left->id ) { + return $left->get_value() === $right->get_value(); + } + + return strtolower( $left->get_bytes() ) === strtolower( $right->get_bytes() ); + } + + /** + * Check whether a SELECT query uses the MySQL SQL_CALC_FOUND_ROWS modifier. + * + * @param string $query MySQL query. + * @return bool Whether the query asks for FOUND_ROWS tracking. + */ + private function is_sql_calc_found_rows_select_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + if ( null === $this->get_mysql_statement_end_position( $tokens, 1 ) ) { + return false; + } + + if ( WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[1]->id ) { + return true; + } + + return isset( $tokens[2] ) + && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[2]->id; + } + /** * Translate WordPress SELECT SQL_CALC_FOUND_ROWS queries. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 20fa26578..fb87bf6b0 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1243,9 +1243,9 @@ public function test_expression_rewrite_does_not_replace_string_literals(): void } /** - * Tests SELECT DISTINCT term ID queries include ORDER BY expressions. + * Tests SELECT DISTINCT term ID queries hide ORDER BY expressions. */ - public function test_distinct_term_id_order_by_name_includes_order_expression_for_postgresql(): void { + 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)' ); @@ -1256,20 +1256,90 @@ public function test_distinct_term_id_order_by_name_includes_order_expression_fo $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"; + 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 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 DISTINCT t.term_id, t.name 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', + '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(), ), ), @@ -1277,6 +1347,31 @@ public function test_distinct_term_id_order_by_name_includes_order_expression_fo ); } + /** + * 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. */ @@ -1330,6 +1425,67 @@ public function test_found_rows_returns_last_sql_calc_found_rows_count(): void { $this->assertSame( array(), $driver->get_last_postgresql_queries() ); } + /** + * 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, 10"; + $rows = $driver->query( $select ); + + $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] ) ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $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 10 OFFSET 0', + $queries[0]['sql'] + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests grouped DISTINCT ORDER BY shapes fail closed for later SELECT passes. + */ + public function test_distinct_order_by_grouped_shape_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + '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' + ); + + $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 MySQL date/time extraction functions are translated for PostgreSQL. */ From 7997b156042517d8b638d7f4f553823149ed6f8e Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 13:46:47 +0000 Subject: [PATCH 051/142] Guard DISTINCT order rewrites --- .../postgresql/class-wp-postgresql-driver.php | 56 ++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 81 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) 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 index 1117ee7e4..2d212758c 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3610,6 +3610,10 @@ private function translate_distinct_order_by_query( string $query ): ?string { ++$position; } + if ( isset( $tokens[ $position ] ) && $this->is_unsupported_distinct_select_modifier( $tokens[ $position ] ) ) { + return null; + } + $projection_start = $position; $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); if ( null === $statement_end ) { @@ -3733,6 +3737,28 @@ private function translate_distinct_order_by_query( string $query ): ?string { ); } + /** + * Check whether a token is an unsupported SELECT modifier for this rewrite. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an unsupported modifier. + */ + private function is_unsupported_distinct_select_modifier( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::SQL_BIG_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_BUFFER_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_NO_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_SMALL_RESULT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + true + ); + } + /** * Parse SELECT projection items with expression bounds and visible aliases. * @@ -4019,7 +4045,7 @@ private function find_mysql_projection_for_order_expression( array $tokens, int return $ordinal; } - $alias = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + $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'] ) ) { @@ -4032,6 +4058,34 @@ private function find_mysql_projection_for_order_expression( array $tokens, int return null; } + /** + * Get a one-token ORDER BY alias reference. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Alias value, or null when unsupported. + */ + private function get_mysql_order_by_alias_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return null; + } + + $value = $token->get_value(); + if ( $this->is_mysql_unquoted_projection_alias_value( $value ) ) { + return $value; + } + + return null; + } + /** * Resolve a positional ORDER BY item to a projection index. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index fb87bf6b0..6ae7b8648 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1347,6 +1347,87 @@ public function test_distinct_parenthesized_user_id_order_by_hides_login_order_c ); } + /** + * 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. */ From f13d742059ee0dbb2fc398ccb42e6572bd2ffe0b Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 14:56:29 +0000 Subject: [PATCH 052/142] Repair PostgreSQL identity sequences after explicit inserts --- .../postgresql/class-wp-postgresql-driver.php | 311 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 292 +++++++++++++++- 2 files changed, 573 insertions(+), 30 deletions(-) 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 index 2d212758c..04132a569 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -368,6 +368,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } $translated_for_postgresql = false; + $dml_identity_repair_query = null; $translated_query = $this->translate_wordpress_options_regexp_delete_query( $query ); if ( null !== $translated_query ) { @@ -396,19 +397,23 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $replace_query = $this->translate_simple_mysql_replace_query( $query ); if ( null !== $replace_query ) { if ( null !== $replace_query['conflict_column'] ) { - $replace_return_value = $this->replace_conflict_exists( + $replace_conflict_exists = $this->replace_conflict_exists( $replace_query['table_name'], $replace_query['conflict_column'], $replace_query['conflict_value'] - ) ? 2 : 1; + ); + $replace_return_value = $replace_conflict_exists ? 2 : 1; + $replace_query['inserted_new_row'] = ! $replace_conflict_exists; } $query = $replace_query['sql']; + $dml_identity_repair_query = $replace_query; $translated_for_postgresql = true; } - $translated_query = $this->translate_simple_mysql_insert_query( $query ); - if ( null !== $translated_query ) { - $query = $translated_query; + $insert_query = $this->translate_simple_mysql_insert_query( $query ); + if ( null !== $insert_query ) { + $query = $insert_query['sql']; + $dml_identity_repair_query = $insert_query; $translated_for_postgresql = true; } @@ -451,6 +456,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo 'params' => array(), ); + $affected_rows = $stmt->rowCount(); + if ( $stmt->columnCount() > 0 ) { $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); @@ -459,12 +466,16 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } } else { $this->last_column_meta = array(); - $this->last_result = $stmt->rowCount(); + $this->last_result = $affected_rows; if ( null !== $replace_return_value ) { $this->last_result = $replace_return_value; } } + if ( null !== $dml_identity_repair_query ) { + $this->repair_dml_identity_sequences_after_success( $dml_identity_repair_query, $affected_rows ); + } + return $this->last_result; } @@ -3211,10 +3222,14 @@ private function translate_simple_mysql_replace_query( string $query ): ?array { $conflict_column = $this->get_simple_replace_conflict_column( $table_name, $columns ); if ( null === $conflict_column ) { return array( - 'sql' => $sql, - 'table_name' => $table_name, - 'conflict_column' => null, - 'conflict_value' => null, + 'action' => 'replace', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'conflict_column' => null, + 'conflict_value' => null, + 'inserted_new_row' => true, ); } @@ -3240,15 +3255,19 @@ private function translate_simple_mysql_replace_query( string $query ): ?array { } return array( - 'sql' => sprintf( + 'action' => 'replace', + 'sql' => sprintf( '%s ON CONFLICT (%s) DO UPDATE SET %s', $sql, $this->connection->quote_identifier( $conflict_column ), implode( ', ', $assignments ) ), - 'table_name' => $table_name, - 'conflict_column' => $conflict_column, - 'conflict_value' => $values[ $conflict_index ], + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'conflict_column' => $conflict_column, + 'conflict_value' => $values[ $conflict_index ], + 'inserted_new_row' => true, ); } @@ -3324,9 +3343,9 @@ private function get_simple_replace_conflict_column( string $table_name, array $ * trailing clauses fall through unchanged. * * @param string $query MySQL query. - * @return string|null PostgreSQL query, or null when the query is unsupported. + * @return array|null PostgreSQL query data, or null when the query is unsupported. */ - private function translate_simple_mysql_insert_query( string $query ): ?string { + private function translate_simple_mysql_insert_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { return null; @@ -3359,27 +3378,263 @@ private function translate_simple_mysql_insert_query( string $query ): ?string { return null; } - $values_start = $position; ++$position; - - $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); - if ( null === $statement_end ) { - return null; - } - - $values_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); - if ( null === $values_end || $values_end !== $statement_end ) { + $values = $this->parse_mysql_value_list( $tokens, $position ); + if ( null === $values || count( $columns ) !== count( $values ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { return null; } $sql = sprintf( - 'INSERT INTO %s (%s) %s', + 'INSERT INTO %s (%s) VALUES (%s)', $this->connection->quote_identifier( $table_name ), implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $values_start, $values_end ) + implode( ', ', $values ) ); - return $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql; + return array( + 'action' => 'insert', + 'sql' => $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'ignore' => $ignore, + 'inserted_new_row' => true, + ); + } + + /** + * Repair PostgreSQL identity sequences for successful explicit identity writes. + * + * @param array $dml_query Translated DML query metadata. + * @param int $affected_rows Backend affected row count. + */ + private function repair_dml_identity_sequences_after_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + return; + } + + if ( isset( $dml_query['inserted_new_row'] ) && ! $dml_query['inserted_new_row'] ) { + return; + } + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'], $dml_query['values'] ) + || ! is_array( $dml_query['columns'] ) + || ! is_array( $dml_query['values'] ) + ) { + return; + } + + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup( + $dml_query['columns'], + $dml_query['values'] + ); + if ( empty( $explicit_identity_columns ) || ! $this->is_postgresql_catalog_available_for_dml_identity_repair() ) { + return; + } + + $table_name = (string) $dml_query['table_name']; + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $metadata = $this->get_dml_identity_column_metadata( $table_schema, $table_name ); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( ! isset( $explicit_identity_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + if ( ! $this->is_existing_dbdelta_column_identity( $column_metadata ) ) { + continue; + } + + $sequence_schema = (string) ( $column_metadata['sequence_schema'] ?? '' ); + $sequence_name = (string) ( $column_metadata['sequence_name'] ?? '' ); + if ( '' === $sequence_schema || '' === $sequence_name ) { + continue; + } + + $this->repair_postgresql_identity_sequence( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name + ); + } + } + + /** + * Get explicitly supplied non-default DML identity columns. + * + * @param string[] $columns DML column names. + * @param string[] $values Translated DML value expressions. + * @return array Lowercase column lookup. + */ + private function get_explicit_dml_identity_column_lookup( array $columns, array $values ): array { + $explicit_columns = array(); + + foreach ( $columns as $index => $column ) { + if ( ! isset( $values[ $index ] ) || ! $this->is_explicit_dml_identity_value( (string) $values[ $index ] ) ) { + continue; + } + + $explicit_columns[ strtolower( (string) $column ) ] = true; + } + + return $explicit_columns; + } + + /** + * Check whether a DML value is an explicit identity value. + * + * DEFAULT and NULL do not represent caller-supplied auto_increment values. + * + * @param string $value_sql Translated value SQL. + * @return bool Whether the value is explicit. + */ + private function is_explicit_dml_identity_value( string $value_sql ): bool { + $value_sql = trim( $value_sql ); + if ( '' === $value_sql ) { + return false; + } + + return ! in_array( strtoupper( $value_sql ), array( 'DEFAULT', 'NULL' ), true ); + } + + /** + * Get PostgreSQL/MySQL metadata for DML identity repair. + * + * @param string $table_schema Backend table schema. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_dml_identity_column_metadata( string $table_schema, string $table_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT + c.column_name, + c.data_type, + c.is_identity, + c.column_default, + cm.column_type AS mysql_column_type, + cm.extra AS mysql_extra, + seq_ns.nspname AS sequence_schema, + seq.relname AS sequence_name + FROM information_schema.columns c + LEFT JOIN %s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name + LEFT JOIN LATERAL ( + SELECT pg_catalog.pg_get_serial_sequence(format(\'%%I.%%I\', c.table_schema, c.table_name), c.column_name)::regclass AS sequence_oid + ) identity_sequence ON TRUE + LEFT JOIN pg_catalog.pg_class seq + ON seq.oid = identity_sequence.sequence_oid + LEFT JOIN pg_catalog.pg_namespace seq_ns + ON seq_ns.oid = seq.relnamespace + WHERE c.table_schema = ? + AND c.table_name = ? + ORDER BY c.ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Check whether PostgreSQL catalog metadata is available for identity repair. + * + * @return bool Whether catalog-backed identity repair can run. + */ + private function is_postgresql_catalog_available_for_dml_identity_repair(): bool { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'pgsql' === $driver_name ) { + return true; + } + + if ( 'sqlite' === $driver_name ) { + return $this->sqlite_information_schema_columns_table_exists(); + } + + return false; + } + + /** + * Check whether the SQLite test shim has an information_schema.columns fixture. + * + * @return bool Whether the fixture table exists. + */ + private function sqlite_information_schema_columns_table_exists(): bool { + $stmt = $this->connection->query( 'PRAGMA database_list' ); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $database ) { + if ( isset( $database['name'] ) && 'information_schema' === $database['name'] ) { + $tables = $this->connection->query( + "SELECT 1 FROM information_schema.sqlite_master WHERE type = 'table' AND name = 'columns' LIMIT 1" + ); + return false !== $tables->fetchColumn(); + } + } + + return false; + } + + /** + * Monotonically synchronize a PostgreSQL identity sequence with its table. + * + * @param string $table_schema Backend table schema. + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_schema Sequence schema. + * @param string $sequence_name Sequence name. + */ + private function repair_postgresql_identity_sequence( + string $table_schema, + string $table_name, + string $column_name, + string $sequence_schema, + string $sequence_name + ): void { + $sequence_identifier = $this->get_postgresql_qualified_identifier( $sequence_schema, $sequence_name ); + $sql = sprintf( + 'WITH sequence_state AS ( + SELECT last_value, is_called FROM %1$s + ), + table_state AS ( + SELECT MAX(%2$s) AS max_identity_value FROM %3$s + ) + SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true) + FROM sequence_state, table_state + WHERE table_state.max_identity_value IS NOT NULL + AND ( + table_state.max_identity_value > sequence_state.last_value + OR (table_state.max_identity_value = sequence_state.last_value AND NOT sequence_state.is_called) + )', + $sequence_identifier, + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $params = array( $sequence_identifier ); + + $this->connection->query( $sql, $params ); + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + /** + * Quote a schema-qualified PostgreSQL identifier. + * + * @param string $schema_name Schema name. + * @param string $object_name Object name. + * @return string Quoted schema-qualified identifier. + */ + private function get_postgresql_qualified_identifier( string $schema_name, string $object_name ): string { + return $this->connection->quote_identifier( $schema_name ) . '.' . $this->connection->quote_identifier( $object_name ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 6ae7b8648..215778243 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -75,6 +75,91 @@ public function test_simple_wordpress_insert_with_backticks_is_translated_to_pos $this->assertSame( 'admin', $rows[0]->user_login ); } + /** + * Tests explicit identity INSERT statements repair PostgreSQL sequences after success. + */ + public function test_explicit_identity_insert_repairs_sequence_after_success(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'identity')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("term_id", "name") VALUES (7, \'identity\')', $queries[0]['sql'] ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests implicit identity INSERT statements do not repair PostgreSQL sequences. + */ + public function test_implicit_identity_insert_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_terms` (`name`) VALUES ('implicit')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("name") VALUES (\'implicit\')', $queries[0]['sql'] ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests INSERT IGNORE no-op conflicts do not repair PostgreSQL sequences. + */ + public function test_insert_ignore_noop_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_terms (term_id, name) VALUES (7, \'existing\')' ); + + $insert = "INSERT IGNORE INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'duplicate')"; + + $this->assertSame( 0, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("term_id", "name") VALUES (7, \'duplicate\') ON CONFLICT DO NOTHING', $queries[0]['sql'] ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests failed explicit identity INSERT statements do not repair PostgreSQL sequences. + */ + public function test_failed_identity_insert_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_terms (term_id, name) VALUES (7, \'existing\')' ); + + try { + $driver->query( "INSERT INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'duplicate')" ); + $this->fail( 'Duplicate explicit identity INSERT should fail before sequence repair.' ); + } catch ( PDOException $e ) { + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + } + /** * Tests simple WordPress REPLACE statements update through PostgreSQL upserts. */ @@ -102,6 +187,56 @@ public function test_simple_wordpress_replace_with_existing_id_is_translated_to_ $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); } + /** + * Tests REPLACE insert paths with explicit identity values repair PostgreSQL sequences. + */ + public function test_replace_insert_path_with_explicit_identity_repairs_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_users', 'ID', 'wptests_users_ID_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Donny Kerabatsos')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Donny Kerabatsos\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_users', 'ID', 'wptests_users_ID_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests REPLACE conflict update paths do not repair PostgreSQL sequences. + */ + public function test_replace_conflict_update_path_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_users', 'ID', 'wptests_users_ID_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", display_name) VALUES (2, \'Walter Sobchak\')' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Walter Replace Sobchak')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + /** * Tests simple REPLACE without a known conflict column falls back to INSERT. */ @@ -2391,6 +2526,51 @@ private function create_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Get a DML identity metadata fixture row. + * + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_name Sequence name. + * @return array[] Fixture metadata rows. + */ + private function get_dml_identity_metadata_fixture( string $table_name, string $column_name, string $sequence_name ): array { + return array( + array( + 'table_schema' => 'public', + 'table_name' => $table_name, + 'column_name' => $column_name, + 'ordinal_position' => 1, + 'data_type' => 'bigint', + 'is_identity' => 'YES', + 'column_default' => null, + 'mysql_column_type' => 'bigint(20)', + 'mysql_extra' => 'auto_increment', + 'sequence_schema' => 'public', + 'sequence_name' => $sequence_name, + ), + ); + } + + /** + * Assert that a logged query is a guarded identity sequence repair query. + * + * @param array $query Logged query. + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_name Sequence name. + */ + private function assert_sequence_repair_query( array $query, string $table_name, string $column_name, string $sequence_name ): void { + $sequence_identifier = '"public"."' . $sequence_name . '"'; + + $this->assertSame( array( $sequence_identifier ), $query['params'] ); + $this->assertStringContainsString( 'SELECT last_value, is_called FROM ' . $sequence_identifier, $query['sql'] ); + $this->assertStringContainsString( 'MAX("' . $column_name . '") AS max_identity_value FROM "public"."' . $table_name . '"', $query['sql'] ); + $this->assertStringContainsString( 'SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true)', $query['sql'] ); + $this->assertStringContainsString( 'table_state.max_identity_value > sequence_state.last_value', $query['sql'] ); + $this->assertStringContainsString( 'NOT sequence_state.is_called', $query['sql'] ); + } + /** * Creates a PostgreSQL driver with a SQLite shim for SUBSTRING(text, pattern). * @@ -2698,27 +2878,135 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive * Fixture connection that accepts PostgreSQL ALTER TABLE syntax in driver tests. */ class WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection extends WP_PostgreSQL_Connection { + /** + * Whether DML identity metadata rows are installed. + * + * @var bool + */ + private $has_identity_metadata_fixture = false; + + /** + * Number of sequence repair queries executed. + * + * @var int + */ + private $sequence_sync_query_count = 0; + /** * Constructor. + * + * @param array[] $identity_metadata_rows Optional fixture identity metadata rows. */ - public function __construct() { + public function __construct( array $identity_metadata_rows = array() ) { parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + + if ( ! empty( $identity_metadata_rows ) ) { + $this->install_information_schema_marker(); + $this->install_identity_metadata_fixture( $identity_metadata_rows ); + $this->has_identity_metadata_fixture = true; + } } /** - * Execute a query, accepting PostgreSQL ALTER TABLE statements as no-ops. + * Execute a query against PostgreSQL test fixtures when needed. * * @param string $sql SQL query. * @param array $params Query parameters. * @return PDOStatement Statement. */ public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.pg_get_serial_sequence' ) ) { + return parent::query( + 'SELECT + column_name, + data_type, + is_identity, + column_default, + mysql_column_type, + mysql_extra, + sequence_schema, + sequence_name + FROM dml_identity_metadata_fixture + WHERE table_schema = ? + AND table_name = ? + ORDER BY ordinal_position', + array( $params[0] ?? '', $params[1] ?? '' ) + ); + } + + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.setval' ) ) { + ++$this->sequence_sync_query_count; + return parent::query( 'SELECT 1' ); + } + if ( 0 === strpos( $sql, 'ALTER TABLE ' ) ) { return parent::query( 'SELECT 1 WHERE 0 = 1' ); } return parent::query( $sql, $params ); } + + /** + * Get the number of sequence repair queries executed. + * + * @return int Sequence repair query count. + */ + public function get_sequence_sync_query_count(): int { + return $this->sequence_sync_query_count; + } + + /** + * Install the information_schema marker used by the SQLite test shim. + */ + private function install_information_schema_marker(): void { + $pdo = $this->get_pdo(); + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( 'CREATE TABLE information_schema.columns (table_schema TEXT)' ); + } + + /** + * Install identity metadata rows. + * + * @param array[] $identity_metadata_rows Fixture identity metadata rows. + */ + private function install_identity_metadata_fixture( array $identity_metadata_rows ): void { + parent::query( + 'CREATE TABLE dml_identity_metadata_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + data_type TEXT NOT NULL, + is_identity TEXT NOT NULL, + column_default TEXT, + mysql_column_type TEXT, + mysql_extra TEXT NOT NULL, + sequence_schema TEXT, + sequence_name TEXT + )' + ); + + foreach ( $identity_metadata_rows as $row ) { + parent::query( + 'INSERT INTO dml_identity_metadata_fixture + (table_schema, table_name, column_name, ordinal_position, data_type, is_identity, column_default, mysql_column_type, mysql_extra, sequence_schema, sequence_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $row['table_schema'] ?? 'public', + $row['table_name'], + $row['column_name'], + $row['ordinal_position'] ?? 1, + $row['data_type'] ?? 'bigint', + $row['is_identity'] ?? 'YES', + $row['column_default'] ?? null, + $row['mysql_column_type'] ?? 'bigint(20)', + $row['mysql_extra'] ?? 'auto_increment', + $row['sequence_schema'] ?? 'public', + $row['sequence_name'], + ) + ); + } + } } /** From d2a415676aedec580f742d80bf22d0efb1653386 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 15:53:28 +0000 Subject: [PATCH 053/142] Coerce integer string predicates with metadata --- .../postgresql/class-wp-postgresql-driver.php | 760 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 148 ++++ 2 files changed, 904 insertions(+), 4 deletions(-) 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 index 04132a569..1eb33e47e 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -1013,6 +1013,57 @@ private function get_mysql_column_nullable( string $table_schema, string $table_ return false === $nullable ? 'YES' : (string) $nullable; } + /** + * Get the stored MySQL type for a table column. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string|null MySQL column type, or null when unavailable. + */ + private function get_mysql_table_column_type( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_type FROM %s + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $column_type = $stmt->fetchColumn(); + return false === $column_type ? null : (string) $column_type; + } + + /** + * Check whether stored MySQL metadata exists for a table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return bool Whether metadata exists. + */ + private function mysql_table_has_column_metadata( string $table_schema, string $table_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + /** * Translate supported dbDelta ALTER TABLE statements to PostgreSQL. * @@ -3700,11 +3751,13 @@ private function translate_simple_mysql_update_query( string $query ): ?string { return null; } - $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( $tokens, $where_position + 1, - $statement_end + $statement_end, + $this->get_mysql_single_table_scope( $table_name ) ); + $sql .= ' WHERE ' . $where_sql['sql']; } return $sql; @@ -3815,11 +3868,13 @@ private function translate_simple_mysql_select_query( string $query ): ?string { ); if ( null !== $where_position ) { - $sql .= ' WHERE ' . $this->translate_mysql_token_sequence_to_postgresql( + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( $tokens, $where_position + 1, - $where_end + $where_end, + $this->get_mysql_single_table_scope( $table_name ) ); + $sql .= ' WHERE ' . $where_sql['sql']; } if ( null !== $order_position ) { @@ -4647,6 +4702,16 @@ private function translate_sql_calc_found_rows_select_query( string $query ): ?s $select_end = $limit_position; } + $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( + $tokens, + 2, + $statement_end, + false + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $select_end ); if ( null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); @@ -4692,6 +4757,18 @@ private function translate_mysql_compatible_query( string $query ): ?string { 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; + } + } + if ( ! $this->needs_mysql_compatible_rewrite( $tokens, 0, $statement_end ) ) { return null; } @@ -5045,6 +5122,643 @@ private function translate_simple_select_projection_to_postgresql( array $tokens return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); } + /** + * Translate a SELECT while coercing integer-column string literals in its WHERE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First token after SELECT modifiers to render. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $require_predicate_change Whether unchanged predicates should fall through. + * @return string|null PostgreSQL SELECT SQL, or null when no safe contextual translation applies. + */ + private function translate_mysql_select_statement_with_integer_string_coercion( + array $tokens, + int $projection_start, + int $statement_end, + bool $require_predicate_change + ): ?string { + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $where_position ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $where_position + ); + if ( null === $from_position ) { + return null; + } + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $where_position ); + if ( null === $scope ) { + return null; + } + + $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 ( $require_predicate_change && ! $where_sql['changed'] ) { + return null; + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $where_position ) + . ' WHERE ' . $where_sql['sql']; + + if ( $where_end < $statement_end ) { + $sql .= ' ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_end, $statement_end ); + } + + return $sql; + } + + /** + * Translate predicate tokens with metadata-backed integer string coercion. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, changed: bool} Translated predicate SQL and change flag. + */ + private function translate_mysql_predicate_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope + ): array { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( + 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_predicate = $this->translate_mysql_integer_column_string_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null === $translated_predicate ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = $translated_predicate['sql']; + $segment_start = $translated_predicate['position'] + 1; + $position = $translated_predicate['position']; + $changed = true; + } + + if ( ! $changed ) { + return array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + 'changed' => false, + ); + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ); + } + + return array( + 'sql' => implode( ' ', array_filter( $chunks, 'strlen' ) ), + 'changed' => true, + ); + } + + /** + * Translate one integer-column predicate against string literals. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $in_predicate = $this->translate_mysql_integer_column_string_in_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $in_predicate ) { + return $in_predicate; + } + + return $this->translate_mysql_integer_column_string_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + } + + /** + * Translate an integer-column IN list containing string literals. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_in_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + $in_position = $reference['end']; + $not_sql = ''; + if ( + isset( $tokens[ $in_position ], $tokens[ $in_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $in_position ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $in_position + 1 ]->id + ) { + $not_sql = ' NOT'; + $in_position += 1; + } + + if ( + ! isset( $tokens[ $in_position ], $tokens[ $in_position + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $in_position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $in_position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $in_position + 2, $after_close - 1 ); + if ( null === $items ) { + return null; + } + + $changed = false; + $item_sql = array(); + foreach ( $items as $item ) { + if ( $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + $item_sql[] = $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $item['start'] ] ) + ); + $changed = true; + continue; + } + + $item_sql[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item['start'], $item['end'] ); + } + + if ( ! $changed ) { + return null; + } + + if ( ! $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s%s IN (%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + implode( ', ', $item_sql ) + ), + 'position' => $after_close - 1, + ); + } + + /** + * Translate an integer-column comparison against a string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && isset( $tokens[ $reference['end'] ], $tokens[ $reference['end'] + 1 ] ) + && $reference['end'] + 1 < $end + && $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + && $this->is_mysql_string_literal_token( $tokens[ $reference['end'] + 1 ] ) + && $this->is_mysql_integer_column_reference( $reference, $scope ) + ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $tokens[ $reference['end'] ]->get_bytes(), + $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $reference['end'] + 1 ] ) + ) + ), + 'position' => $reference['end'] + 1, + ); + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + || ! $this->is_mysql_comparison_operator_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position + 2, $end ); + if ( null === $reference || ! $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ) + ), + $tokens[ $position + 1 ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Build a single-table statement scope. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @param string $schema Metadata schema. + * @return array Statement scope. + */ + private function get_mysql_single_table_scope( + string $table_name, + ?string $alias = null, + string $schema = 'public' + ): array { + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( $schema, $table_name ), + 'table' => $table_name, + ); + + return array( + 'tables' => array( $table ), + 'aliases' => array( + strtolower( null === $alias ? $table_name : $alias ) => $table, + ), + ); + } + + /** + * Parse top-level SELECT table references into a metadata lookup scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token after FROM. + * @param int $end Final FROM-clause token, exclusive. + * @return array|null Statement scope, or null when ambiguous/unsupported. + */ + private function get_mysql_select_scope( array $tokens, int $start, int $end ): ?array { + $scope = array( + 'tables' => array(), + 'aliases' => array(), + 'unknown' => false, + ); + $position = $start; + $expect_next = true; + + while ( $position < $end ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_parentheses = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_parentheses ) { + return null; + } + + $position = $after_parentheses; + if ( $expect_next ) { + $scope['unknown'] = true; + $position = $this->skip_mysql_table_alias( $tokens, $position, $end ); + $expect_next = false; + } + continue; + } + + if ( $expect_next ) { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( $reference['schema'], $reference['table'] ), + 'table' => $reference['table'], + ); + $alias = strtolower( null === $reference['alias'] ? $reference['table'] : $reference['alias'] ); + if ( isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $scope['tables'][] = $table; + $scope['aliases'][ $alias ] = $table; + $position = $reference['position']; + $expect_next = false; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_next = true; + } + + ++$position; + } + + return empty( $scope['tables'] ) || $expect_next ? null : $scope; + } + + /** + * Parse a simple table reference and optional alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @param int $end Final FROM-clause token, exclusive. + * @return array{schema: string, table: string, alias: string|null, position: int}|null Parsed table reference. + */ + private function parse_mysql_table_reference( array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + $schema = 'public'; + $table = $first_identifier; + ++$position; + + if ( $position + 1 < $end && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) { + $second_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $second_identifier ) { + return null; + } + + $schema = $first_identifier; + $table = $second_identifier; + $position += 2; + } + + $alias = null; + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + ++$position; + } + } + + return array( + 'schema' => $schema, + 'table' => $table, + 'alias' => $alias, + 'position' => $position, + ); + } + + /** + * Skip a derived-table alias when one is present. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final FROM-clause token, exclusive. + * @return int Position after the alias. + */ + private function skip_mysql_table_alias( array $tokens, int $position, int $end ): int { + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + return null === $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) + ? $position + : $position + 2; + } + + return null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + ? $position + : $position + 1; + } + + /** + * Check whether a token starts a JOIN table operand. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a JOIN separator. + */ + private function is_mysql_join_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::JOIN_SYMBOL === $token->id || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token->id; + } + + /** + * Parse a simple column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Column reference start position. + * @param int $end Final token position, exclusive. + * @return array{start: int, end: int, qualifier: string|null, column: string}|null Parsed reference. + */ + private function parse_mysql_column_reference( array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $column = $this->get_mysql_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column ) { + return null; + } + + return array( + 'start' => $position, + 'end' => $position + 3, + 'qualifier' => $first_identifier, + 'column' => $column, + ); + } + + return array( + 'start' => $position, + 'end' => $position + 1, + 'qualifier' => null, + 'column' => $first_identifier, + ); + } + + /** + * Check whether a column reference resolves to one integer-family MySQL column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference is a known integer column. + */ + private function is_mysql_integer_column_reference( array $reference, array $scope ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + return null !== $column_type && $this->is_mysql_integer_family_column_type( $column_type ); + } + + /** + * Resolve a column reference to stored MySQL column type metadata. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null MySQL column type, or null when missing/ambiguous. + */ + private function get_mysql_column_type_for_reference( array $reference, array $scope ): ?string { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + return $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ); + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_type = null; + foreach ( $scope['tables'] as $table ) { + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + $column_type = $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ); + if ( null === $column_type ) { + continue; + } + + if ( null !== $matched_type ) { + return null; + } + + $matched_type = $column_type; + } + + return $matched_type; + } + + /** + * Check whether a token range is exactly one string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the range is one string literal. + */ + private function is_mysql_string_literal_range( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end && isset( $tokens[ $start ] ) && $this->is_mysql_string_literal_token( $tokens[ $start ] ); + } + + /** + * Check whether a token is a string literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a string literal. + */ + private function is_mysql_string_literal_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id; + } + + /** + * Check whether a token is a simple comparison operator. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a comparison operator. + */ + private function is_mysql_comparison_operator_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + ), + true + ); + } + /** * Validate the simple expression fragments used by translated DML/SELECT. * @@ -5283,6 +5997,44 @@ private function find_top_level_mysql_token( array $tokens, int $token_id, int $ return null; } + /** + * Find the first top-level token matching any supplied token ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int[] $token_ids Token IDs to find. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return int|null Token position, or null when not found. + */ + private function find_first_top_level_mysql_token( array $tokens, array $token_ids, int $start, int $end ): ?int { + $lookup = array(); + foreach ( $token_ids as $token_id ) { + $lookup[ $token_id ] = true; + } + + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && isset( $lookup[ $tokens[ $i ]->id ] ) ) { + return $i; + } + } + + return null; + } + /** * Check whether a bounded token range contains any top-level token IDs. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 215778243..588977b8c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1218,6 +1218,154 @@ public function test_field_function_returns_zero_for_null_and_missing_values(): $this->assertSame( '2', $rows[0]->alpha_position ); } + /** + * Tests integer-column IN predicates coerce string literals using stored MySQL metadata. + */ + public function test_integer_column_in_string_literals_use_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + `comment_ID` bigint(20) unsigned NOT NULL, + `comment_post_ID` bigint(20) unsigned NOT NULL DEFAULT 0, + `comment_approved` varchar(20) NOT NULL DEFAULT "1", + PRIMARY KEY (`comment_ID`) + )' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (1, 0, \'0\')' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (2, 1, \'0\')' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (3, 0, \'1\')' + ); + + $select = "SELECT comment_post_ID, COUNT(comment_ID) as num_comments + FROM wptests_comments + WHERE comment_post_ID IN ('') AND comment_approved = '0' + GROUP BY comment_post_ID"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->comment_post_ID ); + $this->assertSame( '1', $rows[0]->num_comments ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( + '"comment_post_ID" IN (' . $this->get_expected_mysql_integer_cast_sql( "''" ) . ')', + $sql + ); + $this->assertStringContainsString( "comment_approved = '0'", $sql ); + $this->assertStringNotContainsString( 'CAST(comment_approved AS text)', $sql ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS user searches coerce bad ID terms without breaking LIKE terms. + */ + public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_predicate(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (1, \'admin\')' ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (2, \'match-yololololo\')' ); + + $select = "SELECT SQL_CALC_FOUND_ROWS ID, user_login + FROM wptests_users + WHERE ID = 'yololololo' OR user_login LIKE '%yololololo%' + ORDER BY ID ASC"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( 'match-yololololo', $rows[0]->user_login ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); + $this->assertStringContainsString( + '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), + $sql + ); + $this->assertStringContainsString( "user_login LIKE '%yololololo%'", $sql ); + } + + /** + * Tests text columns keep lexical string comparisons even when numeric-looking values are present. + */ + public function test_text_columns_preserve_lexical_string_comparisons(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `meta_id` bigint(20) unsigned NOT NULL, + `meta_value` longtext NOT NULL, + PRIMARY KEY (`meta_id`) + )' + ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (7, \'007\')' ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (8, \'7\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (`meta_id`, `meta_value`) VALUES (1, \'10abc\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (`meta_id`, `meta_value`) VALUES (2, \'10\')' ); + + $user_rows = $driver->query( "SELECT ID FROM wptests_users WHERE user_login = '007'" ); + + $this->assertCount( 1, $user_rows ); + $this->assertSame( '7', $user_rows[0]->ID ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $meta_rows = $driver->query( "SELECT meta_id FROM wptests_postmeta WHERE meta_value = '10abc'" ); + + $this->assertCount( 1, $meta_rows ); + $this->assertSame( '1', $meta_rows[0]->meta_id ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests ambiguous unqualified integer references do not guess a table. + */ + public function test_ambiguous_unqualified_integer_reference_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_left_ids ( + `ID` bigint(20) NOT NULL, + `label` varchar(20) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_right_ids ( + `ID` bigint(20) NOT NULL, + `label` varchar(20) NOT NULL + )' + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_left_ids, wptests_right_ids WHERE ID = 'abc'" + ); + + $this->assertSame( 'SELECT * FROM wptests_left_ids, wptests_right_ids WHERE "ID" = \'abc\'', $sql ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $sql ); + } + /** * Tests MySQL SIGNED and UNSIGNED casts coerce text safely for PostgreSQL. */ From 8db8ffc9f8373c41aa38008593111e00c8e1db3d Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 17:47:59 +0000 Subject: [PATCH 054/142] Translate Site Health information schema query --- .../postgresql/class-wp-postgresql-driver.php | 466 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 141 +++++- 2 files changed, 605 insertions(+), 2 deletions(-) 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 index 1eb33e47e..e040fb3b8 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -425,6 +425,12 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); + $translated_query = $this->translate_information_schema_tables_site_health_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -3891,6 +3897,442 @@ private function translate_simple_mysql_select_query( string $query ): ?string { return $sql; } + /** + * Translate WordPress Site Health's MySQL information_schema.TABLES query. + * + * WordPress asks MySQL for TABLE_ROWS and data/index lengths, which + * PostgreSQL's information_schema.tables does not expose. Keep this rewrite + * constrained to the Site Health projection and predicates so other catalog + * shapes continue to fail visibly. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_information_schema_tables_site_health_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $statement_end ); + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $from_position + 1, $statement_end ); + if ( + null === $where_position + || null === $group_position + || $where_position > $group_position + || ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + if ( ! $this->is_information_schema_tables_reference( $tokens, $from_position + 1, $where_position ) ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, 1, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $projection_sql = $this->get_information_schema_tables_site_health_projection_sql( $tokens, $projection_items ); + if ( null === $projection_sql ) { + return null; + } + + $where_clause = $this->parse_information_schema_tables_site_health_where_clause( $tokens, $where_position + 1, $group_position ); + if ( null === $where_clause ) { + return null; + } + + if ( ! $this->is_information_schema_tables_site_health_group_by_clause( $tokens, $group_position + 2, $statement_end ) ) { + return null; + } + + return sprintf( + 'SELECT %s FROM (%s) AS %s WHERE %s GROUP BY %s', + implode( ', ', $projection_sql ), + $this->get_information_schema_tables_site_health_relation_sql( $where_clause['table_names'] ), + $this->connection->quote_identifier( '__wp_pg_information_schema_tables' ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $group_position ), + $this->connection->quote_identifier( 'table_name' ) + ); + } + + /** + * Check whether a token range is exactly information_schema.TABLES. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token position. + * @param int $end Final table-reference token position, exclusive. + * @return bool Whether the range references information_schema.TABLES. + */ + private function is_information_schema_tables_reference( array $tokens, int $start, int $end ): bool { + return $start + 3 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, 'information_schema' ) + && isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 2 ] ?? null, 'tables' ); + } + + /** + * Build Site Health's supported information_schema.TABLES projection list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @return string[]|null PostgreSQL projection SQL, or null when unsupported. + */ + private function get_information_schema_tables_site_health_projection_sql( array $tokens, array $projection_items ): ?array { + if ( 3 !== count( $projection_items ) ) { + return null; + } + + $expected = array( + array( + 'alias' => 'table', + 'type' => 'table_name', + ), + array( + 'alias' => 'rows', + 'type' => 'table_rows', + ), + array( + 'alias' => 'bytes', + 'type' => 'data_index_sum', + ), + ); + + $projection_sql = array(); + foreach ( $expected as $index => $expected_projection ) { + $projection_item = $projection_items[ $index ]; + if ( strtolower( $projection_item['alias'] ) !== $expected_projection['alias'] ) { + return null; + } + + if ( + 'table_name' === $expected_projection['type'] + && $this->is_information_schema_tables_column_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + 'table_name' + ) + ) { + $projection_sql[] = sprintf( + '%s AS %s', + $this->connection->quote_identifier( 'table_name' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + if ( + 'table_rows' === $expected_projection['type'] + && $this->is_information_schema_tables_column_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + 'table_rows' + ) + ) { + $projection_sql[] = sprintf( + 'MAX(%s) AS %s', + $this->connection->quote_identifier( 'TABLE_ROWS' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + if ( + 'data_index_sum' === $expected_projection['type'] + && $this->is_information_schema_tables_data_index_sum_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'] + ) + ) { + $projection_sql[] = sprintf( + 'SUM(%s + %s) AS %s', + $this->connection->quote_identifier( 'data_length' ), + $this->connection->quote_identifier( 'index_length' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + return null; + } + + return $projection_sql; + } + + /** + * Check whether a projection expression is a supported information_schema.TABLES column. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param string $column Expected column name. + * @return bool Whether the expression is the expected column. + */ + private function is_information_schema_tables_column_expression( array $tokens, int $start, int $end, string $column ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + return $bounds['start'] + 1 === $bounds['end'] + && $this->is_mysql_identifier_like_token_value( $tokens[ $bounds['start'] ] ?? null, $column ); + } + + /** + * Check whether a projection expression is SUM(data_length + index_length). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return bool Whether the expression is the supported size aggregate. + */ + private function is_information_schema_tables_data_index_sum_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return $start + 6 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ], $tokens[ $start + 5 ] ) + && WP_MySQL_Lexer::SUM_SYMBOL === $tokens[ $start ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 2 ], 'data_length' ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start + 3 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 4 ], 'index_length' ) + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $start + 5 ]->id; + } + + /** + * Parse a supported Site Health information_schema.TABLES WHERE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token position. + * @param int $end Final WHERE predicate token position, exclusive. + * @return array{table_names: string[]}|null Parsed WHERE data, or null when unsupported. + */ + private function parse_information_schema_tables_site_health_where_clause( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $position = $start; + $seen_columns = array(); + $table_names = array(); + $required_seen = array( + 'table_schema' => false, + 'table_name' => false, + ); + + while ( $position < $end ) { + $term = $this->parse_information_schema_tables_site_health_where_term( $tokens, $position, $end ); + if ( null === $term || isset( $seen_columns[ $term['column'] ] ) ) { + return null; + } + + $seen_columns[ $term['column'] ] = true; + if ( isset( $required_seen[ $term['column'] ] ) ) { + $required_seen[ $term['column'] ] = true; + } + if ( 'table_name' === $term['column'] ) { + $table_names = $term['table_names']; + } + $position = $term['position']; + + if ( $position === $end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + + if ( ! $required_seen['table_schema'] || ! $required_seen['table_name'] || empty( $table_names ) ) { + return null; + } + + return array( + 'table_names' => array_values( array_unique( $table_names ) ), + ); + } + + /** + * Parse one supported information_schema.TABLES WHERE term. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final WHERE predicate token position, exclusive. + * @return array{column: string, position: int, table_names: string[]}|null Parsed term, or null when unsupported. + */ + private function parse_information_schema_tables_site_health_where_term( array $tokens, int $position, int $end ): ?array { + if ( $this->is_mysql_identifier_like_token_value( $tokens[ $position ] ?? null, 'table_schema' ) ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id + || ! $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + ) { + return null; + } + + return array( + 'column' => 'table_schema', + 'position' => $position + 3, + 'table_names' => array(), + ); + } + + if ( ! $this->is_mysql_identifier_like_token_value( $tokens[ $position ] ?? null, 'table_name' ) ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 1 ]->id + && $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + ) { + return array( + 'column' => 'table_name', + 'position' => $position + 3, + 'table_names' => array( $tokens[ $position + 2 ]->get_value() ), + ); + } + + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 2, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $position + 3, $after_close - 1 ); + if ( null === $items || count( $items ) < 1 ) { + return null; + } + + $table_names = array(); + foreach ( $items as $item ) { + if ( ! $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + return null; + } + $table_names[] = $tokens[ $item['start'] ]->get_value(); + } + + return array( + 'column' => 'table_name', + 'position' => $after_close, + 'table_names' => $table_names, + ); + } + + /** + * Check whether the GROUP BY clause is exactly GROUP BY TABLE_NAME. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First GROUP BY expression token position. + * @param int $end Final GROUP BY token position, exclusive. + * @return bool Whether the grouping shape is supported. + */ + private function is_information_schema_tables_site_health_group_by_clause( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, 'table_name' ); + } + + /** + * Build the derived relation that emulates MySQL information_schema.TABLES columns. + * + * @param string[] $table_names Table names from the validated TABLE_NAME predicate. + * @return string PostgreSQL relation SQL. + */ + private function get_information_schema_tables_site_health_relation_sql( array $table_names ): string { + return sprintf( + 'SELECT %1$s AS %1$s, %2$s AS %3$s, %4$s, 0 AS %5$s, 0 AS %6$s FROM %7$s WHERE %8$s = %9$s AND %10$s IN (%11$s, %12$s) AND %1$s NOT IN (%13$s, %14$s, %15$s)', + $this->connection->quote_identifier( 'table_name' ), + $this->connection->quote( $this->db_name ), + $this->connection->quote_identifier( 'TABLE_SCHEMA' ), + $this->get_information_schema_tables_site_health_table_rows_sql( $table_names ), + $this->connection->quote_identifier( 'data_length' ), + $this->connection->quote_identifier( 'index_length' ), + $this->get_postgresql_qualified_identifier( 'information_schema', 'tables' ), + $this->connection->quote_identifier( 'table_schema' ), + $this->connection->quote( 'public' ), + $this->connection->quote_identifier( 'table_type' ), + $this->connection->quote( 'BASE TABLE' ), + $this->connection->quote( 'VIEW' ), + $this->connection->quote( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_INDEX_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHARSET_METADATA_TABLE ) + ); + } + + /** + * Build a CASE expression for Site Health TABLE_ROWS emulation. + * + * @param string[] $table_names Table names from the validated TABLE_NAME predicate. + * @return string PostgreSQL row-count expression SQL. + */ + private function get_information_schema_tables_site_health_table_rows_sql( array $table_names ): string { + $cases = array(); + foreach ( $table_names as $table_name ) { + $cases[] = sprintf( + 'WHEN %s THEN (SELECT COUNT(*) FROM %s)', + $this->connection->quote( $table_name ), + $this->connection->quote_identifier( $table_name ) + ); + } + + return sprintf( + 'CASE %s %s ELSE 0 END AS %s', + $this->connection->quote_identifier( 'table_name' ), + implode( ' ', $cases ), + $this->connection->quote_identifier( 'TABLE_ROWS' ) + ); + } + /** * Translate SELECT DISTINCT queries whose ORDER BY expression is not selected. * @@ -6952,6 +7394,30 @@ private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token ): ?st return null; } + /** + * Check whether a token is an identifier-like token with the expected value. + * + * Some MySQL information_schema column names, such as TABLE_NAME, are lexed + * as keyword tokens. Treat them like identifiers only in explicit catalog + * translator contexts. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param string $value Expected identifier value. + * @return bool Whether the token has the expected identifier-like value. + */ + private function is_mysql_identifier_like_token_value( ?WP_MySQL_Token $token, string $value ): bool { + if ( null === $token ) { + return false; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null === $identifier && WP_MySQL_Lexer::TABLE_NAME_SYMBOL === $token->id ) { + $identifier = $token->get_value(); + } + + return null !== $identifier && strtolower( $identifier ) === strtolower( $value ); + } + /** * Check whether a token can represent a MySQL character set name. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 588977b8c..0e070872e 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2466,6 +2466,114 @@ function ( $row ) { ); } + /** + * Tests Site Health's information_schema.TABLES query returns MySQL-shaped rows. + */ + public function test_information_schema_tables_site_health_query_returns_mysql_shape_with_single_quoted_aliases(): void { + $driver = $this->create_driver( 'wordpress_develop_tests' ); + $this->install_information_schema_fixture( $driver ); + $this->install_site_health_table_count_fixture( $driver ); + + $query = "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_comments','wptests_options','wptests_posts','wptests_terms','wptests_users') + GROUP BY TABLE_NAME;"; + $rows = $driver->query( $query ); + + usort( + $rows, + static function ( $left, $right ): int { + return strcmp( $left->table, $right->table ); + } + ); + + $this->assertCount( 2, $rows ); + $this->assertSame( array( 'table', 'rows', 'bytes' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( 'wptests_options', '2', '0' ), + array( 'wptests_posts', '1', '0' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table, $row->rows, $row->bytes ); + }, + $rows + ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( 'AS "rows"', $sql ); + $this->assertStringContainsString( 'AS "bytes"', $sql ); + $this->assertStringNotContainsString( "AS 'table'", $sql ); + $this->assertStringNotContainsString( "AS 'rows'", $sql ); + $this->assertStringNotContainsString( "AS 'bytes'", $sql ); + $this->assertStringContainsString( '"information_schema"."tables"', $sql ); + $this->assertStringContainsString( "\"TABLE_SCHEMA\" = 'wordpress_develop_tests'", $sql ); + $this->assertStringNotContainsString( '"wordpress_develop_tests"', $sql ); + } + + /** + * Tests single-quoted aliases do not turn catalog predicate literals into identifiers. + */ + public function test_information_schema_tables_site_health_single_quoted_alias_preserves_predicate_string_literals(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_site_health_table_count_fixture( $driver ); + + $query = "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME = 'wptests_options' + GROUP BY TABLE_NAME"; + $rows = $driver->query( $query ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]->table ); + $this->assertSame( '2', $rows[0]->rows ); + $this->assertSame( '0', $rows[0]->bytes ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( "\"TABLE_SCHEMA\" = 'wptests'", $sql ); + $this->assertStringContainsString( "TABLE_NAME = 'wptests_options'", $sql ); + $this->assertStringNotContainsString( '"wptests"', $sql ); + } + + /** + * Tests unsupported information_schema.TABLES shapes do not enter the Site Health translator. + */ + public function test_information_schema_tables_site_health_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + $queries = array( + "SELECT COUNT(*) AS 'rows' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME", + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_ROWS > 0 + GROUP BY TABLE_NAME", + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME + ORDER BY TABLE_NAME", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_information_schema_tables_site_health_query', + $query + ), + $query + ); + } + } + /** * Tests SHOW INDEX returns MySQL-shaped PostgreSQL catalog rows. */ @@ -2669,9 +2777,9 @@ public function test_multi_assignment_set_statement_still_reaches_backend(): voi * * @return WP_PostgreSQL_Driver */ - private function create_driver(): WP_PostgreSQL_Driver { + private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Driver { $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); - return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + return new WP_PostgreSQL_Driver( $connection, $db_name ); } /** @@ -2938,6 +3046,35 @@ private function create_show_index_driver(): WP_PostgreSQL_Driver { return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Install backend tables used by Site Health TABLE_ROWS emulation tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_site_health_table_count_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + $connection = $driver->get_connection(); + $table_names = array( + 'wptests_comments', + 'wptests_options', + 'wptests_posts', + 'wptests_terms', + 'wptests_users', + ); + + foreach ( $table_names as $table_name ) { + $pdo->exec( + sprintf( + 'CREATE TABLE %s (id INTEGER)', + $connection->quote_identifier( $table_name ) + ) + ); + } + + $pdo->exec( 'INSERT INTO ' . $connection->quote_identifier( 'wptests_options' ) . ' (id) VALUES (1), (2)' ); + $pdo->exec( 'INSERT INTO ' . $connection->quote_identifier( 'wptests_posts' ) . ' (id) VALUES (1)' ); + } + /** * Install a small information_schema fixture into the injected PDO. * From 00e163e40f1470bcff453a2fe8c552cd1b2235d4 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 18:08:46 +0000 Subject: [PATCH 055/142] Validate Site Health table rows before counting --- .../postgresql/class-wp-postgresql-driver.php | 76 ++++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 31 ++++---- 2 files changed, 83 insertions(+), 24 deletions(-) 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 index e040fb3b8..47936b9a8 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3985,10 +3985,12 @@ private function translate_information_schema_tables_site_health_query( string $ return null; } + $existing_table_names = $this->get_information_schema_tables_site_health_existing_table_names( $where_clause['table_names'] ); + return sprintf( 'SELECT %s FROM (%s) AS %s WHERE %s GROUP BY %s', implode( ', ', $projection_sql ), - $this->get_information_schema_tables_site_health_relation_sql( $where_clause['table_names'] ), + $this->get_information_schema_tables_site_health_relation_sql( $existing_table_names ), $this->connection->quote_identifier( '__wp_pg_information_schema_tables' ), $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $group_position ), $this->connection->quote_identifier( 'table_name' ) @@ -4283,18 +4285,67 @@ private function is_information_schema_tables_site_health_group_by_clause( array } /** - * Build the derived relation that emulates MySQL information_schema.TABLES columns. + * Get requested Site Health table names that exist in the PostgreSQL catalog. * * @param string[] $table_names Table names from the validated TABLE_NAME predicate. + * @return string[] Existing table names in requested order. + */ + private function get_information_schema_tables_site_health_existing_table_names( array $table_names ): array { + if ( empty( $table_names ) ) { + return array(); + } + + $placeholders = implode( ', ', array_fill( 0, count( $table_names ), '?' ) ); + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s WHERE %3$s = ? AND %4$s IN (?, ?) AND %1$s NOT IN (?, ?, ?) AND %1$s IN (%5$s)', + $this->connection->quote_identifier( 'table_name' ), + $this->get_postgresql_qualified_identifier( 'information_schema', 'tables' ), + $this->connection->quote_identifier( 'table_schema' ), + $this->connection->quote_identifier( 'table_type' ), + $placeholders + ), + array_merge( + array( + 'public', + 'BASE TABLE', + 'VIEW', + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_CHARSET_METADATA_TABLE, + ), + $table_names + ) + ); + + $existing_table_names = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $table_name ) { + $existing_table_names[ (string) $table_name ] = true; + } + + return array_values( + array_filter( + $table_names, + static function ( string $table_name ) use ( $existing_table_names ): bool { + return isset( $existing_table_names[ $table_name ] ); + } + ) + ); + } + + /** + * Build the derived relation that emulates MySQL information_schema.TABLES columns. + * + * @param string[] $existing_table_names Table names validated against information_schema.tables. * @return string PostgreSQL relation SQL. */ - private function get_information_schema_tables_site_health_relation_sql( array $table_names ): string { + private function get_information_schema_tables_site_health_relation_sql( array $existing_table_names ): string { return sprintf( 'SELECT %1$s AS %1$s, %2$s AS %3$s, %4$s, 0 AS %5$s, 0 AS %6$s FROM %7$s WHERE %8$s = %9$s AND %10$s IN (%11$s, %12$s) AND %1$s NOT IN (%13$s, %14$s, %15$s)', $this->connection->quote_identifier( 'table_name' ), $this->connection->quote( $this->db_name ), $this->connection->quote_identifier( 'TABLE_SCHEMA' ), - $this->get_information_schema_tables_site_health_table_rows_sql( $table_names ), + $this->get_information_schema_tables_site_health_table_rows_sql( $existing_table_names ), $this->connection->quote_identifier( 'data_length' ), $this->connection->quote_identifier( 'index_length' ), $this->get_postgresql_qualified_identifier( 'information_schema', 'tables' ), @@ -4310,18 +4361,25 @@ private function get_information_schema_tables_site_health_relation_sql( array $ } /** - * Build a CASE expression for Site Health TABLE_ROWS emulation. + * Build a Site Health TABLE_ROWS expression for existing catalog tables. * - * @param string[] $table_names Table names from the validated TABLE_NAME predicate. + * @param string[] $existing_table_names Table names validated against information_schema.tables. * @return string PostgreSQL row-count expression SQL. */ - private function get_information_schema_tables_site_health_table_rows_sql( array $table_names ): string { + private function get_information_schema_tables_site_health_table_rows_sql( array $existing_table_names ): string { + if ( empty( $existing_table_names ) ) { + return sprintf( + '0 AS %s', + $this->connection->quote_identifier( 'TABLE_ROWS' ) + ); + } + $cases = array(); - foreach ( $table_names as $table_name ) { + foreach ( $existing_table_names as $table_name ) { $cases[] = sprintf( 'WHEN %s THEN (SELECT COUNT(*) FROM %s)', $this->connection->quote( $table_name ), - $this->connection->quote_identifier( $table_name ) + $this->get_postgresql_qualified_identifier( 'public', $table_name ) ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 0e070872e..c95277fb7 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2467,7 +2467,7 @@ function ( $row ) { } /** - * Tests Site Health's information_schema.TABLES query returns MySQL-shaped rows. + * Tests Site Health's information_schema.TABLES query returns rows for existing catalog tables only. */ public function test_information_schema_tables_site_health_query_returns_mysql_shape_with_single_quoted_aliases(): void { $driver = $this->create_driver( 'wordpress_develop_tests' ); @@ -2477,7 +2477,7 @@ public function test_information_schema_tables_site_health_query_returns_mysql_s $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') + AND TABLE_NAME IN ('wptests_options','wptests_missing','wptests_posts') GROUP BY TABLE_NAME;"; $rows = $driver->query( $query ); @@ -2513,6 +2513,8 @@ static function ( $row ): array { $this->assertStringContainsString( '"information_schema"."tables"', $sql ); $this->assertStringContainsString( "\"TABLE_SCHEMA\" = 'wordpress_develop_tests'", $sql ); $this->assertStringNotContainsString( '"wordpress_develop_tests"', $sql ); + $this->assertStringNotContainsString( 'FROM "wptests_missing"', $sql ); + $this->assertStringNotContainsString( 'FROM "public"."wptests_missing"', $sql ); } /** @@ -3052,27 +3054,26 @@ private function create_show_index_driver(): WP_PostgreSQL_Driver { * @param WP_PostgreSQL_Driver $driver Driver under test. */ private function install_site_health_table_count_fixture( WP_PostgreSQL_Driver $driver ): void { - $pdo = $driver->get_connection()->get_pdo(); - $connection = $driver->get_connection(); - $table_names = array( - 'wptests_comments', - 'wptests_options', - 'wptests_posts', - 'wptests_terms', - 'wptests_users', - ); + $pdo = $driver->get_connection()->get_pdo(); + $connection = $driver->get_connection(); + $schema = $connection->quote_identifier( 'public' ); - foreach ( $table_names as $table_name ) { + $pdo->exec( "ATTACH DATABASE ':memory:' AS public" ); + foreach ( array( 'wptests_options', 'wptests_posts' ) as $table_name ) { $pdo->exec( sprintf( - 'CREATE TABLE %s (id INTEGER)', + 'CREATE TABLE %s.%s (id INTEGER)', + $schema, $connection->quote_identifier( $table_name ) ) ); } - $pdo->exec( 'INSERT INTO ' . $connection->quote_identifier( 'wptests_options' ) . ' (id) VALUES (1), (2)' ); - $pdo->exec( 'INSERT INTO ' . $connection->quote_identifier( 'wptests_posts' ) . ' (id) VALUES (1)' ); + $options_table = $schema . '.' . $connection->quote_identifier( 'wptests_options' ); + $posts_table = $schema . '.' . $connection->quote_identifier( 'wptests_posts' ); + + $pdo->exec( 'INSERT INTO ' . $options_table . ' (id) VALUES (1), (2)' ); + $pdo->exec( 'INSERT INTO ' . $posts_table . ' (id) VALUES (1)' ); } /** From d7148a698f13e59e5d3238af0869e4ba1c616e93 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 19:55:26 +0000 Subject: [PATCH 056/142] Fix strict PostgreSQL grouped ordering --- .../postgresql/class-wp-postgresql-driver.php | 725 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 159 ++++ 2 files changed, 884 insertions(+) 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 index 47936b9a8..929503df5 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -431,6 +431,12 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -5053,6 +5059,614 @@ private function get_distinct_order_by_outer_order_sql( array $projection_items, return implode( ', ', $order_sql ); } + /** + * Translate aggregate/grouped SELECT ORDER BY clauses that PostgreSQL rejects. + * + * MySQL permits non-grouped ORDER BY expressions in grouped queries. Keep + * this rewrite limited to WordPress's scalar count and grouped archive/comment + * ID query shapes so unsupported grouping semantics still fail visibly. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_strict_aggregate_grouped_order_by_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + if ( + null === $order_position + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $select_end + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $select_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + 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::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, 1, $order_position ); + if ( null === $group_position ) { + return $this->translate_strict_aggregate_only_order_by_query( + $tokens, + $order_position, + $limit_position, + $statement_end + ); + } + + if ( + ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + || $group_position + 2 >= $order_position + ) { + return null; + } + + return $this->translate_strict_grouped_order_by_query( + $tokens, + $group_position, + $order_position, + $limit_position, + $statement_end + ); + } + + /** + * Drop ORDER BY from scalar COUNT-only aggregate queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_strict_aggregate_only_order_by_query( + array $tokens, + int $order_position, + ?int $limit_position, + int $statement_end + ): ?string { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $order_position ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + if ( ! $this->is_mysql_count_only_projection( $tokens, 1, $from_position ) ) { + return null; + } + + $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $order_position ); + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate targeted grouped ORDER BY expressions to aggregate-safe forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_strict_grouped_order_by_query( + array $tokens, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end + ): ?string { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $group_position ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, 1, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $order_position ); + if ( null === $group_items || count( $group_items ) < 1 ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $order_position + 2, + $select_end, + $projection_items + ); + if ( null === $order_items ) { + return null; + } + + $archive_date_expression = $this->get_mysql_archive_grouped_date_expression_bounds( $tokens, $group_items ); + $is_comment_id_group = $this->is_mysql_comment_id_grouped_select_shape( $tokens, $projection_items, $group_items ); + if ( null === $archive_date_expression && ! $is_comment_id_group ) { + return null; + } + + $order_sql = array(); + $rewritten = false; + foreach ( $order_items as $order_item ) { + if ( + null !== $order_item['projection_index'] + || $this->is_mysql_grouped_order_expression( $tokens, $order_item, $group_items ) + ) { + $order_sql[] = $order_item['sql'] . ' ' . $order_item['direction']; + continue; + } + + if ( + null !== $archive_date_expression + && $this->is_mysql_archive_post_date_order_expression( $tokens, $order_item, $archive_date_expression ) + ) { + $order_sql[] = $this->get_strict_grouped_aggregate_order_sql( $order_item ); + $rewritten = true; + continue; + } + + if ( + $is_comment_id_group + && $this->is_mysql_comment_id_grouped_order_expression( $tokens, $order_item ) + ) { + $order_sql[] = $this->get_strict_grouped_aggregate_order_sql( $order_item ); + $rewritten = true; + continue; + } + + return null; + } + + if ( ! $rewritten ) { + return null; + } + + $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $order_position ) + . ' ORDER BY ' . implode( ', ', $order_sql ); + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Check whether a projection is exactly one COUNT aggregate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the projection is COUNT-only. + */ + private function is_mysql_count_only_projection( array $tokens, int $start, int $end ): bool { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || 1 !== count( $ranges ) ) { + return false; + } + + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $ranges[0]['start'], + $ranges[0]['end'] + ); + if ( null === $expression_bounds ) { + return false; + } + + return $this->is_mysql_count_aggregate_expression( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + } + + /** + * Get expression bounds for a projection item, excluding any alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{start: int, end: int}|null Expression bounds, or null when malformed. + */ + private function get_mysql_select_projection_expression_bounds( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + if ( null !== $as_position ) { + if ( + $as_position <= $start + || $as_position + 2 !== $end + || null === $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ) + ) { + return null; + } + + $expression_end = $as_position; + } else { + $implicit_alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null !== $implicit_alias ) { + $expression_end = $end - 1; + } + } + + if ( $start >= $expression_end ) { + return null; + } + + return array( + 'start' => $start, + 'end' => $expression_end, + ); + } + + /** + * Check whether an expression is a COUNT aggregate call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is COUNT(...). + */ + private function is_mysql_count_aggregate_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + && $this->is_mysql_token_value( $tokens[ $start ], 'count' ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ) === $end; + } + + /** + * Get the shared post_date expression from YEAR(post_date), MONTH(post_date) grouping. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return array{start: int, end: int}|null Shared post_date expression bounds, or null. + */ + private function get_mysql_archive_grouped_date_expression_bounds( array $tokens, array $group_items ): ?array { + if ( 2 !== count( $group_items ) ) { + return null; + } + + $year_expression = null; + $month_expression = null; + foreach ( $group_items as $group_item ) { + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'YEAR' + ); + if ( null !== $expression ) { + $year_expression = $expression; + continue; + } + + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'MONTH' + ); + if ( null !== $expression ) { + $month_expression = $expression; + } + } + + if ( + null === $year_expression + || null === $month_expression + || ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $month_expression['start'], + $month_expression['end'] + ) + || ! $this->is_mysql_column_reference_expression( + $tokens, + $year_expression['start'], + $year_expression['end'], + 'post_date', + 'posts', + true + ) + ) { + return null; + } + + return $year_expression; + } + + /** + * Get the argument expression for a supported date/time extract function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $unit Expected date/time unit. + * @return array{start: int, end: int}|null Argument bounds, or null. + */ + private function get_mysql_extract_argument_expression_bounds( array $tokens, int $start, int $end, string $unit ): ?array { + $expression_bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $bounds = $this->get_mysql_extract_function_bounds( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( + null === $bounds + || $bounds['unit'] !== $unit + || $bounds['close'] + 1 !== $expression_bounds['end'] + ) { + return null; + } + + return array( + 'start' => $bounds['expression_start'], + 'end' => $bounds['expression_end'], + ); + } + + /** + * Check whether ORDER BY references the archive post_date expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_archive_post_date_order_expression( array $tokens, array $order_item, array $archive_date_expression ): bool { + return $this->are_mysql_token_ranges_equivalent( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $archive_date_expression['start'], + $archive_date_expression['end'] + ) || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date', + 'posts', + true + ); + } + + /** + * Check whether a SELECT is grouped by the selected comments.comment_ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether this is the supported comment ID grouped shape. + */ + private function is_mysql_comment_id_grouped_select_shape( array $tokens, array $projection_items, array $group_items ): bool { + return 1 === count( $projection_items ) + && 1 === count( $group_items ) + && $this->is_mysql_comment_id_expression( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'] + ) + && $this->is_mysql_comment_id_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ); + } + + /** + * Check whether an expression references comments.comment_ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is comments.comment_ID. + */ + private function is_mysql_comment_id_expression( array $tokens, int $start, int $end ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, 'comment_ID', 'comments', true ); + } + + /** + * Check whether a grouped comment ID ORDER BY expression can be aggregated. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_comment_id_grouped_order_expression( array $tokens, array $order_item ): bool { + if ( + $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'comment_date', + 'comments', + false + ) + || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'comment_date_gmt', + 'comments', + false + ) + || $this->is_mysql_qualified_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'meta_value' + ) + ) { + return true; + } + + $cast_bounds = $this->get_mysql_character_cast_bounds( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + if ( null === $cast_bounds || $cast_bounds['close'] + 1 !== $order_item['expression_end'] ) { + return false; + } + + return $this->is_mysql_qualified_column_reference_expression( + $tokens, + $cast_bounds['expression_start'], + $cast_bounds['expression_end'], + 'meta_value' + ); + } + + /** + * Check whether an ORDER BY expression is already valid for the GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the expression is grouped. + */ + private function is_mysql_grouped_order_expression( array $tokens, array $order_item, array $group_items ): bool { + foreach ( $group_items as $group_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + return true; + } + } + + return false; + } + + /** + * Build an aggregate-safe ORDER BY item for grouped SELECTs. + * + * @param array $order_item Parsed ORDER BY item. + * @return string PostgreSQL ORDER BY item SQL. + */ + private function get_strict_grouped_aggregate_order_sql( array $order_item ): string { + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + + return sprintf( + '%s(%s) %s', + $aggregate_function, + $order_item['sql'], + $order_item['direction'] + ); + } + + /** + * Check whether an expression is a supported column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $column_name Expected column name. + * @param string|null $qualifier_suffix Optional table-name suffix for qualified references. + * @param bool $allow_bare Whether unqualified references are allowed. + * @return bool Whether the expression is a supported column reference. + */ + private function is_mysql_column_reference_expression( + array $tokens, + int $start, + int $end, + string $column_name, + ?string $qualifier_suffix, + bool $allow_bare + ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $allow_bare && $start + 1 === $end ) { + $identifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + return null !== $identifier && strtolower( $identifier ) === strtolower( $column_name ); + } + + if ( + $start + 3 !== $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $qualifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ); + if ( null === $qualifier || null === $column || strtolower( $column ) !== strtolower( $column_name ) ) { + return false; + } + + return null === $qualifier_suffix + || strtolower( $qualifier ) === strtolower( $qualifier_suffix ) + || '_' . strtolower( $qualifier_suffix ) === substr( strtolower( $qualifier ), -1 * ( strlen( $qualifier_suffix ) + 1 ) ); + } + + /** + * Check whether an expression is a qualified column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $column_name Expected column name. + * @return bool Whether the expression is a qualified column reference. + */ + private function is_mysql_qualified_column_reference_expression( array $tokens, int $start, int $end, string $column_name ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, $column_name, null, false ); + } + /** * Check whether two expression token ranges are structurally equivalent. * @@ -6591,6 +7205,9 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_integer_cast_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_character_cast_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ); } @@ -6871,6 +7488,99 @@ private function get_postgresql_integer_cast_type( array $tokens, int $start, in return null; } + /** + * Translate MySQL CAST(expr AS CHAR) to PostgreSQL text. + * + * PostgreSQL's CHAR without length is character(1), while MySQL CHAR casts + * are used by WordPress as text ordering expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_character_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_character_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL character CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_character_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_character_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL CHAR. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_character_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $start ]->id; + } + /** * Translate MySQL REGEXP/RLIKE operators to PostgreSQL regex operators. * @@ -7476,6 +8186,21 @@ private function is_mysql_identifier_like_token_value( ?WP_MySQL_Token $token, s return null !== $identifier && strtolower( $identifier ) === strtolower( $value ); } + /** + * Check whether a token's semantic value matches a keyword or identifier. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param string $value Expected value. + * @return bool Whether the token value matches. + */ + private function is_mysql_token_value( ?WP_MySQL_Token $token, string $value ): bool { + if ( null === $token ) { + return false; + } + + return strtolower( $token->get_value() ) === strtolower( $value ); + } + /** * Check whether a token can represent a MySQL character set name. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index c95277fb7..36b74bbc2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1850,6 +1850,165 @@ public function test_distinct_order_by_grouped_shape_fails_closed(): void { $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 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 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. */ From f8c01ab36499d3f163d21cd5a8a8f688f895d400 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 21:48:12 +0000 Subject: [PATCH 057/142] Translate MySQL date arithmetic for PostgreSQL --- .../postgresql/class-wp-postgresql-driver.php | 217 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 181 ++++++++++++++- 2 files changed, 372 insertions(+), 26 deletions(-) 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 index 929503df5..048c05200 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -7214,6 +7214,9 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_rand_function_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); } @@ -7753,6 +7756,166 @@ private function split_top_level_mysql_arguments( array $tokens, int $start, int return $arguments; } + /** + * Translate MySQL DATE_ADD(expr, INTERVAL value unit) and DATE_SUB(...) calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_arithmetic_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_arithmetic_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['interval_value_start'], + $bounds['interval_value_end'] + ); + + return array( + 'sql' => sprintf( + '(%1$s %2$s (%3$s * INTERVAL %4$s))', + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $bounds['operator'], + $this->get_postgresql_mysql_interval_value_sql( $value_sql ), + $this->connection->quote( '1 ' . $bounds['interval_unit'] ) + ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL DATE_ADD/DATE_SUB expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{operator: string, expression_start: int, expression_end: int, interval_value_start: int, interval_value_end: int, interval_unit: string, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_arithmetic_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + ), + true + ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + + $interval = $this->get_mysql_interval_argument_bounds( $tokens, $arguments[1]['start'], $arguments[1]['end'] ); + if ( null === $interval ) { + return null; + } + + return array( + 'operator' => WP_MySQL_Lexer::DATE_SUB_SYMBOL === $tokens[ $position ]->id ? '-' : '+', + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'interval_value_start' => $interval['value_start'], + 'interval_value_end' => $interval['value_end'], + 'interval_unit' => $interval['unit'], + 'close' => $after_close - 1, + ); + } + + /** + * Get token bounds for a supported MySQL INTERVAL value unit argument. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First interval token position. + * @param int $end Final interval token position, exclusive. + * @return array{value_start: int, value_end: int, unit: string}|null Bounds, or null when unsupported. + */ + private function get_mysql_interval_argument_bounds( array $tokens, int $start, int $end ): ?array { + if ( + $start + 3 > $end + || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::INTERVAL_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + + $unit = $this->get_postgresql_simple_interval_unit( $tokens[ $end - 1 ] ); + if ( null === $unit ) { + return null; + } + + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => $unit, + ); + } + + /** + * Get a PostgreSQL interval unit for supported simple MySQL interval units. + * + * @param WP_MySQL_Token $token MySQL interval unit token. + * @return string|null PostgreSQL interval unit, or null when unsupported. + */ + private function get_postgresql_simple_interval_unit( WP_MySQL_Token $token ): ?string { + switch ( $token->id ) { + case WP_MySQL_Lexer::SECOND_SYMBOL: + return 'second'; + + case WP_MySQL_Lexer::MINUTE_SYMBOL: + return 'minute'; + + case WP_MySQL_Lexer::HOUR_SYMBOL: + return 'hour'; + + case WP_MySQL_Lexer::DAY_SYMBOL: + return 'day'; + + case WP_MySQL_Lexer::WEEK_SYMBOL: + return 'week'; + + case WP_MySQL_Lexer::MONTH_SYMBOL: + return 'month'; + + case WP_MySQL_Lexer::YEAR_SYMBOL: + return 'year'; + } + + return null; + } + + /** + * Get PostgreSQL SQL for a MySQL-compatible interval value. + * + * @param string $value_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_interval_value_sql( string $value_sql ): string { + return sprintf( 'CAST(%s AS double precision)', $this->get_postgresql_mysql_integer_cast_sql( $value_sql ) ); + } + /** * Translate supported MySQL date/time extract functions to PostgreSQL. * @@ -7793,25 +7956,47 @@ private function translate_mysql_date_time_extract_to_postgresql( array $tokens, * @return string PostgreSQL expression SQL. */ private function get_postgresql_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { - $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); - $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; - $zero_date_condition = 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 - ); - $timestamp_expression_sql = sprintf( - 'CASE WHEN %1$s THEN NULL ELSE %2$s END', - $zero_date_condition, - $expression_text_sql - ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); return sprintf( - 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM %4$s) AS integer) END', $zero_date_condition, $this->get_postgresql_zero_date_extract_part_sql( $unit, $expression_text_sql ), $unit, - $timestamp_expression_sql + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get PostgreSQL SQL that casts a MySQL date/time expression without casting zero dates. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s THEN NULL ELSE %2$s END AS timestamp)', + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + + /** + * Get a condition that detects MySQL zero or partial-zero date strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_postgresql_zero_date_condition_sql( string $expression_text_sql ): string { + $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; + + return sprintf( + '%1$s ~ %2$s AND (SUBSTRING(%1$s FROM 1 FOR 4) = \'0000\' OR SUBSTRING(%1$s FROM 6 FOR 2) = \'00\' OR SUBSTRING(%1$s FROM 9 FOR 2) = \'00\')', + $expression_text_sql, + $date_text_pattern ); } @@ -8289,6 +8474,10 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_date_arithmetic_function_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $i, $end ) ) { return true; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 36b74bbc2..a5a34f6e8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2161,6 +2161,113 @@ public function test_wordpress_date_query_extract_functions_are_translated_to_po ); } + /** + * Tests WordPress DATE_ADD queries are translated for PostgreSQL. + */ + public function test_wordpress_date_add_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(comment_date_gmt, INTERVAL '0' SECOND) FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'comment_date_gmt', "'0'", 'second' ) . " FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1", + $sql + ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests DATE_SUB queries are detected even without another rewrite trigger. + */ + public function test_mysql_date_sub_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_SUB(post_date_gmt, INTERVAL 1 DAY) AS older'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '1', 'day' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'DATE_SUB', $sql ); + } + + /** + * Tests DATE_ADD supports the simple MySQL interval units used by WordPress. + */ + public function test_mysql_date_add_supports_simple_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'YEAR' => 'year', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 2 ' . $mysql_unit . ') AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', $postgresql_unit ) . ' AS shifted', + $sql, + $mysql_unit + ); + } + } + + /** + * Tests DATE_ADD parsing handles nested expressions and lowercase interval syntax. + */ + public function test_mysql_date_add_handles_nested_and_lowercase_interval_arguments(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_ADD(COALESCE(post_date_gmt, post_date), interval (1 + 1) day) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'COALESCE (post_date_gmt, post_date)', '(1 + 1)', 'day' ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD timestamp casts are guarded for MySQL zero-date values. + */ + public function test_mysql_date_add_guards_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD('0000-00-00 00:00:00', INTERVAL 1 DAY) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', "'0000-00-00 00:00:00'", '1', 'day' ) . ' AS shifted', + $sql + ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CAST('0000-00-00 00:00:00' AS text) END AS timestamp", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + } + + /** + * Tests unsupported DATE_ADD interval units fall through without semantic rewriting. + */ + public function test_mysql_date_add_with_unsupported_interval_unit_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 DAY_SECOND) AS shifted' + ); + + $this->assertNull( $sql ); + } + /** * Tests unsupported ON DUPLICATE KEY INSERT shapes still reach PDO. */ @@ -3054,6 +3161,35 @@ private function get_expected_mysql_integer_cast_sql( string $expression_sql ): ); } + /** + * Get expected PostgreSQL SQL for MySQL DATE_ADD/DATE_SUB arithmetic. + * + * @param string $operator PostgreSQL interval operator. + * @param string $expression_sql PostgreSQL date/time expression SQL. + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit PostgreSQL interval unit. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_date_arithmetic_sql( string $operator, string $expression_sql, string $value_sql, string $unit ): string { + return sprintf( + '(%1$s %2$s (%3$s * INTERVAL \'1 %4$s\'))', + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ), + $operator, + $this->get_expected_mysql_interval_value_sql( $value_sql ), + $unit + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL-compatible interval value. + * + * @param string $value_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_interval_value_sql( string $value_sql ): string { + return sprintf( 'CAST(%s AS double precision)', $this->get_expected_mysql_integer_cast_sql( $value_sql ) ); + } + /** * Get expected zero-date-safe PostgreSQL date/time extract SQL. * @@ -3062,23 +3198,44 @@ private function get_expected_mysql_integer_cast_sql( string $expression_sql ): * @return string PostgreSQL expression SQL. */ private function get_expected_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { - $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); - $zero_date_condition = 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 - ); - $timestamp_expression_sql = sprintf( - 'CASE WHEN %1$s THEN NULL ELSE %2$s END', - $zero_date_condition, - $expression_text_sql - ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_expected_zero_date_condition_sql( $expression_text_sql ); return sprintf( - 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM CAST(%4$s AS timestamp)) AS integer) END', + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM %4$s) AS integer) END', $zero_date_condition, $this->get_expected_zero_date_extract_part_sql( $unit, $expression_text_sql ), $unit, - $timestamp_expression_sql + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL that casts a MySQL date/time without casting zero dates. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s THEN NULL ELSE %2$s END AS timestamp)', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + + /** + * Get expected condition that detects MySQL zero or partial-zero date strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_expected_zero_date_condition_sql( string $expression_text_sql ): string { + return sprintf( + '%1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}\' AND (SUBSTRING(%1$s FROM 1 FOR 4) = \'0000\' OR SUBSTRING(%1$s FROM 6 FOR 2) = \'00\' OR SUBSTRING(%1$s FROM 9 FOR 2) = \'00\')', + $expression_text_sql ); } From 2f48d53b4eb056fc6763407b44758ce9653bd7ff Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 22:08:22 +0000 Subject: [PATCH 058/142] Add PostgreSQL non-strict DML defaults --- .../postgresql/class-wp-postgresql-driver.php | 311 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 230 +++++++++++++ 2 files changed, 540 insertions(+), 1 deletion(-) 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 index 048c05200..c148254fc 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3275,6 +3275,8 @@ private function translate_simple_mysql_replace_query( string $query ): ?array { return null; } + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values ); + $sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', $this->connection->quote_identifier( $table_name ), @@ -3447,6 +3449,8 @@ private function translate_simple_mysql_insert_query( string $query ): ?array { return null; } + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values ); + $sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', $this->connection->quote_identifier( $table_name ), @@ -3749,10 +3753,15 @@ private function translate_simple_mysql_update_query( string $query ): ?string { return null; } + $set_sql = $this->translate_simple_mysql_update_set_clause( $table_name, $tokens, $position, $set_end ); + if ( null === $set_sql ) { + return null; + } + $sql = sprintf( 'UPDATE %s SET %s', $this->connection->quote_identifier( $table_name ), - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $set_end ) + $set_sql ); if ( null !== $where_position ) { @@ -3775,6 +3784,306 @@ private function translate_simple_mysql_update_query( string $query ): ?string { return $sql; } + /** + * Append metadata-derived defaults for omitted NOT NULL columns in non-strict DML. + * + * @param string $table_name Table name. + * @param string[] $columns DML columns, mutated when defaults are appended. + * @param string[] $values DML values, mutated when defaults are appended. + */ + private function append_non_strict_dml_defaults_for_omitted_columns( string $table_name, array &$columns, array &$values ): void { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $supplied_columns = array(); + foreach ( $columns as $column ) { + $supplied_columns[ strtolower( (string) $column ) ] = true; + } + + foreach ( $this->get_mysql_dml_column_metadata( $table_name ) as $column_metadata ) { + $column_name = (string) ( $column_metadata['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 ); + if ( null === $default_sql ) { + continue; + } + + $columns[] = $column_name; + $values[] = $default_sql; + $supplied_columns[ strtolower( $column_name ) ] = true; + } + } + + /** + * Translate a supported simple UPDATE SET clause with non-strict NULL coercion. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @return string|null PostgreSQL SET SQL, or null when unsupported. + */ + private function translate_simple_mysql_update_set_clause( string $table_name, array $tokens, int $start, int $end ): ?string { + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $assignments = array(); + + for ( $position = $start; $position < $end; ) { + $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $target_column ) { + return null; + } + + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $value_start = $position + 2; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $assignment_end ) { + return null; + } + + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment_end ); + $target_column_key = strtolower( $target_column ); + $target_metadata = $column_metadata[ $target_column_key ] ?? null; + $coerced_default_sql = null; + + if ( null !== $target_metadata && $this->is_mysql_null_token_sequence( $tokens, $value_start, $assignment_end ) ) { + $coerced_default_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); + } + + $assignments[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + null === $coerced_default_sql ? $value_sql : $coerced_default_sql + ); + + $position = $assignment_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + return count( $assignments ) > 0 ? implode( ', ', $assignments ) : null; + } + + /** + * Get DML column metadata keyed by lowercase column name. + * + * @param string $table_name Table name. + * @return array Column metadata lookup. + */ + private function get_mysql_dml_column_metadata_lookup( string $table_name ): array { + $lookup = array(); + foreach ( $this->get_mysql_dml_column_metadata( $table_name ) as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' !== $column_name ) { + $lookup[ strtolower( $column_name ) ] = $column_metadata; + } + } + + return $lookup; + } + + /** + * Get ordered MySQL column metadata for a DML target table. + * + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_mysql_dml_column_metadata( string $table_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, extra + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get the default SQL expression for a non-strict NOT NULL DML column. + * + * @param array $column_metadata Column metadata row. + * @return string|null Default SQL, or null when the column should not be coerced. + */ + private function get_non_strict_dml_default_sql_for_column( array $column_metadata ): ?string { + if ( 'NO' !== strtoupper( (string) ( $column_metadata['is_nullable'] ?? '' ) ) ) { + return null; + } + + if ( $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + return null; + } + + if ( null !== ( $column_metadata['column_default'] ?? null ) ) { + return $this->connection->quote( (string) $column_metadata['column_default'] ); + } + + return $this->get_mysql_implicit_dml_default_sql( (string) ( $column_metadata['column_type'] ?? '' ) ); + } + + /** + * Check whether column metadata describes a MySQL AUTO_INCREMENT column. + * + * @param array $column_metadata Column metadata row. + * @return bool Whether the column is AUTO_INCREMENT. + */ + private function is_mysql_auto_increment_column_metadata( array $column_metadata ): bool { + return 'auto_increment' === strtolower( (string) ( $column_metadata['extra'] ?? '' ) ); + } + + /** + * Get a MySQL-compatible implicit default for a column type. + * + * @param string $column_type MySQL column type metadata. + * @return string|null SQL default expression, or null for unsupported type metadata. + */ + private function get_mysql_implicit_dml_default_sql( string $column_type ): ?string { + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + + if ( + in_array( + $base_type, + array( + 'char', + 'varchar', + 'binary', + 'varbinary', + 'tinyblob', + 'blob', + 'mediumblob', + 'longblob', + 'tinytext', + 'text', + 'mediumtext', + 'longtext', + 'enum', + 'set', + ), + true + ) + ) { + return $this->connection->quote( '' ); + } + + if ( + in_array( + $base_type, + array( + 'bit', + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'integer', + 'bigint', + 'decimal', + 'numeric', + 'float', + 'double', + 'real', + ), + true + ) + ) { + return '0'; + } + + if ( 'date' === $base_type ) { + return $this->connection->quote( '0000-00-00' ); + } + + if ( 'datetime' === $base_type || 'timestamp' === $base_type ) { + return $this->connection->quote( '0000-00-00 00:00:00' ); + } + + if ( 'time' === $base_type ) { + return $this->connection->quote( '00:00:00' ); + } + + if ( 'year' === $base_type ) { + return $this->connection->quote( '0000' ); + } + + return null; + } + + /** + * Get the base MySQL column type from metadata. + * + * @param string $column_type MySQL column type metadata. + * @return string Base type. + */ + private function get_base_mysql_dml_column_type( string $column_type ): string { + $column_type = strtolower( trim( $column_type ) ); + $type_end = strlen( $column_type ); + + $length_position = strpos( $column_type, '(' ); + if ( false !== $length_position ) { + $type_end = min( $type_end, $length_position ); + } + + $space_position = strpos( $column_type, ' ' ); + if ( false !== $space_position ) { + $type_end = min( $type_end, $space_position ); + } + + return substr( $column_type, 0, $type_end ); + } + + /** + * Check whether a token sequence is exactly the NULL literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the token sequence is NULL. + */ + private function is_mysql_null_token_sequence( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Check whether the emulated MySQL session is using a strict SQL mode. + * + * @return bool Whether strict DML behavior should be preserved. + */ + private function is_mysql_strict_sql_mode_active(): bool { + foreach ( explode( ',', $this->sql_mode ) as $mode ) { + $mode = strtoupper( trim( $mode ) ); + if ( 'STRICT_TRANS_TABLES' === $mode || 'STRICT_ALL_TABLES' === $mode ) { + return true; + } + } + + return false; + } + /** * Translate simple single-table MySQL SELECT statements to PostgreSQL. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index a5a34f6e8..210ef4c9c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -75,6 +75,124 @@ public function test_simple_wordpress_insert_with_backticks_is_translated_to_pos $this->assertSame( 'admin', $rows[0]->user_login ); } + /** + * Tests non-strict INSERT statements append metadata-derived NOT NULL defaults. + */ + public function test_non_strict_insert_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_author_email TEXT NOT NULL, + comment_content TEXT NOT NULL, + comment_parent INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_comments ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_author tinytext NOT NULL, + comment_author_email varchar(100) NOT NULL DEFAULT '', + comment_content text NOT NULL, + comment_parent bigint(20) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (comment_ID) + )" + ); + + $comment_insert = 'INSERT INTO `wptests_comments` (`comment_ID`) VALUES (1)'; + + $this->assertSame( 1, $driver->query( $comment_insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_comments" ("comment_ID", "comment_author", "comment_author_email", "comment_content", "comment_parent") VALUES (1, \'\', \'\', \'\', \'0\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $comments = $driver->query( 'SELECT comment_author, comment_author_email, comment_content, comment_parent FROM wptests_comments WHERE comment_ID = 1' ); + $this->assertSame( '', $comments[0]->comment_author ); + $this->assertSame( '', $comments[0]->comment_author_email ); + $this->assertSame( '', $comments[0]->comment_content ); + $this->assertSame( '0', $comments[0]->comment_parent ); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_date TEXT NOT NULL, + post_content TEXT NOT NULL, + post_title TEXT NOT NULL, + post_excerpt TEXT NOT NULL, + post_status TEXT NOT NULL, + post_parent INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_content longtext NOT NULL, + post_title text NOT NULL, + post_excerpt text NOT NULL, + post_status varchar(20) NOT NULL DEFAULT 'publish', + post_parent bigint(20) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (ID) + )" + ); + + $post_insert = "INSERT INTO `wptests_posts` (`ID`, `post_title`) VALUES (1, 'Post 1')"; + + $this->assertSame( 1, $driver->query( $post_insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("ID", "post_title", "post_date", "post_content", "post_excerpt", "post_status", "post_parent") VALUES (1, \'Post 1\', \'0000-00-00 00:00:00\', \'\', \'\', \'publish\', \'0\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date, post_content, post_excerpt, post_status, post_parent FROM wptests_posts WHERE ID = 1' ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '', $posts[0]->post_content ); + $this->assertSame( '', $posts[0]->post_excerpt ); + $this->assertSame( 'publish', $posts[0]->post_status ); + $this->assertSame( '0', $posts[0]->post_parent ); + } + + /** + * Tests strict SQL mode leaves omitted NOT NULL INSERT columns to fail visibly. + */ + public function test_strict_insert_does_not_append_omitted_not_null_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_comments ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_author tinytext NOT NULL, + comment_content text NOT NULL, + PRIMARY KEY (comment_ID) + )" + ); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' ); + + $this->expectException( PDOException::class ); + + $driver->query( 'INSERT INTO `wptests_comments` (`comment_ID`) VALUES (1)' ); + } + /** * Tests explicit identity INSERT statements repair PostgreSQL sequences after success. */ @@ -187,6 +305,49 @@ public function test_simple_wordpress_replace_with_existing_id_is_translated_to_ $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); } + /** + * Tests non-strict REPLACE applies omitted NOT NULL defaults on insert and conflict paths. + */ + public function test_non_strict_replace_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_options_table_with_mysql_metadata( $driver ); + + $replace = "REPLACE INTO `wptests_options` (`option_name`) VALUES ('siteurl')"; + $expected_sql = 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"'; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => $expected_sql, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + + $driver->query( "UPDATE wptests_options SET option_value = 'custom', autoload = 'no' WHERE option_name = 'siteurl'" ); + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => $expected_sql, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + /** * Tests REPLACE insert paths with explicit identity values repair PostgreSQL sequences. */ @@ -865,6 +1026,50 @@ public function test_simple_wordpress_update_with_backticks_is_translated_to_pos $this->assertSame( 'value2', $rows[0]->option_value ); } + /** + * Tests non-strict UPDATE coerces exact NULL assignments for NOT NULL columns. + */ + public function test_non_strict_update_null_coerces_not_null_columns_to_metadata_defaults(): void { + $driver = $this->create_driver(); + $this->install_options_table_with_mysql_metadata( $driver ); + + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('cron', 'serialized', 'no')" ); + + $update = "UPDATE `wptests_options` SET `option_value` = NULL, `autoload` = NULL WHERE `option_name` = 'cron'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_options" SET "option_value" = \'\', "autoload" = \'yes\' WHERE "option_name" = \'cron\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'cron'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests 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. */ @@ -3040,6 +3245,31 @@ public function test_multi_assignment_set_statement_still_reaches_backend(): voi $driver->query( 'SET foreign_key_checks = 0, unsupported_setting = 1' ); } + /** + * Install a PostgreSQL-like options table and matching MySQL column metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_options_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL, + autoload TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + )" + ); + } + /** * Creates a PostgreSQL driver backed by an injected in-memory PDO. * From 5aa4b179edb85a019619f63cbd8d81a6b24600f9 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 23:02:28 +0000 Subject: [PATCH 059/142] Coerce PostgreSQL numeric metadata contexts --- .../postgresql/class-wp-postgresql-driver.php | 578 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 159 +++++ 2 files changed, 699 insertions(+), 38 deletions(-) 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 index c148254fc..4a0fe37f1 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4827,11 +4827,23 @@ private function translate_distinct_order_by_query( string $query ): ?string { return null; } + $scope_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $order_position + ) ?? $order_position; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + $order_items = $this->parse_mysql_select_order_by_items( $tokens, $order_position + 2, $select_end, - $projection_items + $projection_items, + $scope ); if ( null === $order_items ) { return null; @@ -5091,9 +5103,17 @@ private function get_mysql_select_expression_default_output_name( array $tokens, * @param int $start First ORDER BY item token position. * @param int $end Final ORDER BY token position, exclusive. * @param array $projection_items Parsed projection items. - * @return array|null ORDER BY items. + * @param array|null $scope Optional statement table scope for contextual expression coercions. + * @return array|null + * ORDER BY items. */ - private function parse_mysql_select_order_by_items( array $tokens, int $start, int $end, array $projection_items ): ?array { + 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; @@ -5101,8 +5121,9 @@ private function parse_mysql_select_order_by_items( array $tokens, int $start, i $items = array(); foreach ( $ranges as $range ) { - $expression_end = $range['end']; - $direction = 'ASC'; + $expression_end = $range['end']; + $direction = 'ASC'; + $direction_explicit = false; if ( isset( $tokens[ $expression_end - 1 ] ) @@ -5111,7 +5132,8 @@ private function parse_mysql_select_order_by_items( array $tokens, int $start, i || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $expression_end - 1 ]->id ) ) { - $direction = WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $expression_end - 1 ]->id ? 'DESC' : 'ASC'; + $direction = WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $expression_end - 1 ]->id ? 'DESC' : 'ASC'; + $direction_explicit = true; --$expression_end; } @@ -5119,21 +5141,35 @@ private function parse_mysql_select_order_by_items( array $tokens, int $start, i return null; } - $items[] = array( - 'expression_start' => $range['start'], - 'expression_end' => $expression_end, - 'sql' => $this->translate_mysql_token_sequence_to_postgresql( + $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 - ), - 'direction' => $direction, - 'projection_index' => $this->find_mysql_projection_for_order_expression( + $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'], ); } @@ -6546,19 +6582,19 @@ private function translate_simple_select_projection_to_postgresql( array $tokens } /** - * Translate a SELECT while coercing integer-column string literals in its WHERE clause. + * Translate a SELECT while applying metadata-backed expression coercions. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param int $projection_start First token after SELECT modifiers to render. * @param int $statement_end Final statement token position, exclusive. - * @param bool $require_predicate_change Whether unchanged predicates should fall through. + * @param bool $require_contextual_change Whether unchanged statements should fall through. * @return string|null PostgreSQL SELECT SQL, or null when no safe contextual translation applies. */ private function translate_mysql_select_statement_with_integer_string_coercion( array $tokens, int $projection_start, int $statement_end, - bool $require_predicate_change + bool $require_contextual_change ): ?string { $where_position = $this->find_top_level_mysql_token( $tokens, @@ -6566,26 +6602,28 @@ private function translate_mysql_select_statement_with_integer_string_coercion( $projection_start, $statement_end ); - if ( null === $where_position ) { + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $where_position && null === $order_position ) { return null; } + $first_clause_position = min( array_filter( array( $where_position, $order_position ), 'is_int' ) ); $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, - $where_position + $first_clause_position ); if ( null === $from_position ) { return null; } - $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $where_position ); - if ( null === $scope ) { - return null; - } - - $where_end = $this->find_first_top_level_mysql_token( + $from_end = $this->find_first_top_level_mysql_token( $tokens, array( WP_MySQL_Lexer::FOR_SYMBOL, @@ -6596,29 +6634,240 @@ private function translate_mysql_select_statement_with_integer_string_coercion( WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, ), - $where_position + 1, + $from_position + 1, $statement_end ) ?? $statement_end; - $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $from_end ); + if ( null === $scope ) { + return null; + } + + $replacements = array(); + if ( null !== $where_position ) { + $where_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $where_position + 1, + $statement_end + ) ?? $statement_end; + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $where_end, + 'sql' => $where_sql['sql'], + ); + } + } + + if ( + null !== $order_position + && isset( $tokens[ $order_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $order_position + 1 ]->id + ) { + $order_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $order_position + 2, + $statement_end + ) ?? $statement_end; + + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $order_end, + $scope + ); + if ( $order_sql['changed'] ) { + $replacements[] = array( + 'start' => $order_position + 2, + 'end' => $order_end, + 'sql' => $order_sql['sql'], + ); + } + } + + if ( $require_contextual_change && empty( $replacements ) ) { + return null; + } + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, - $where_position + 1, - $where_end, - $scope + $projection_start, + $statement_end, + $replacements ); - if ( $require_predicate_change && ! $where_sql['changed'] ) { - return null; + } + + /** + * Translate tokens while replacing known bounded token ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_with_replacements_to_postgresql( + array $tokens, + int $start, + int $end, + array $replacements + ): string { + $chunks = array(); + $position = $start; + + foreach ( $replacements as $replacement ) { + if ( $position < $replacement['start'] ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $replacement['start'] ); + } + + $chunks[] = $replacement['sql']; + $position = $replacement['end']; + } + + if ( $position < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $end ); + } + + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + + /** + * Translate ORDER BY items with metadata-backed expression coercions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, changed: bool} Translated ORDER BY SQL and change flag. + */ + private function translate_mysql_order_by_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope + ): 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, + ); } - $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $where_position ) - . ' WHERE ' . $where_sql['sql']; + $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']; + } - if ( $where_end < $statement_end ) { - $sql .= ' ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_end, $statement_end ); + $order_sql[] = $item_sql; } - return $sql; + return array( + 'sql' => $changed + ? implode( ', ', $order_sql ) + : $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + 'changed' => $changed, + ); + } + + /** + * Translate expression tokens with metadata-backed numeric text coercions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, changed: bool} Translated expression SQL and change flag. + */ + private function translate_mysql_expression_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope + ): array { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + $translated_expression = $this->translate_mysql_text_column_numeric_addition_to_postgresql( + $tokens, + $position, + $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, + ); } /** @@ -6715,7 +6964,17 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( return $in_predicate; } - return $this->translate_mysql_integer_column_string_comparison_to_postgresql( + $comparison = $this->translate_mysql_integer_column_string_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $comparison ) { + return $comparison; + } + + return $this->translate_mysql_text_column_numeric_comparison_to_postgresql( $tokens, $position, $end, @@ -6868,6 +7127,130 @@ private function translate_mysql_integer_column_string_comparison_to_postgresql( ); } + /** + * Translate a text-column comparison against a numeric literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_text_column_numeric_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && isset( $tokens[ $reference['end'] ] ) + && $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + && $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + if ( null !== $literal ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_integer_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_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Translate a text-column numeric addition expression used for sorting. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate expression position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_text_column_numeric_addition_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'] ] ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $reference['end'] ]->id + && $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + if ( + null !== $literal + && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + ) { + return array( + 'sql' => $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $literal['end'] - 1, + ); + } + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $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 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' => $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $reference['end'] - 1, + ); + } + /** * Build a single-table statement scope. * @@ -7093,6 +7476,39 @@ private function is_mysql_integer_column_reference( array $reference, array $sco return null !== $column_type && $this->is_mysql_integer_family_column_type( $column_type ); } + /** + * Check whether a column reference resolves to one text-family MySQL column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference is a known text column. + */ + private function is_mysql_text_family_column_reference( array $reference, array $scope ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + return null !== $column_type && $this->is_mysql_text_family_column_type( $column_type ); + } + + /** + * Check whether a MySQL column type belongs to the text family. + * + * @param string $column_type MySQL column type metadata. + * @return bool Whether the type stores textual data. + */ + private function is_mysql_text_family_column_type( string $column_type ): bool { + return in_array( + $this->get_base_mysql_dml_column_type( $column_type ), + array( + 'char', + 'longtext', + 'mediumtext', + 'text', + 'tinytext', + 'varchar', + ), + true + ); + } + /** * Resolve a column reference to stored MySQL column type metadata. * @@ -7151,6 +7567,92 @@ private function is_mysql_string_literal_range( array $tokens, int $start, int $ return $start + 1 === $end && isset( $tokens[ $start ] ) && $this->is_mysql_string_literal_token( $tokens[ $start ] ); } + /** + * Parse a numeric literal, including an optional unary sign. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Literal start position. + * @param int $end Final token position, exclusive. + * @return array{start: int, end: int}|null Numeric literal bounds. + */ + private function parse_mysql_numeric_literal( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + if ( + ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $position ]->id + ) + && isset( $tokens[ $position + 1 ] ) + && $position + 1 < $end + && $this->is_mysql_numeric_literal_token( $tokens[ $position + 1 ] ) + ) { + return array( + 'start' => $position, + 'end' => $position + 2, + ); + } + + if ( $this->is_mysql_numeric_literal_token( $tokens[ $position ] ) ) { + return array( + 'start' => $position, + 'end' => $position + 1, + ); + } + + return null; + } + + /** + * Check whether a numeric literal range represents zero. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return bool Whether the literal is numeric zero. + */ + private function is_mysql_zero_numeric_literal_range( array $tokens, int $start, int $end ): bool { + if ( ! isset( $tokens[ $start ] ) ) { + return false; + } + + if ( + $start + 2 === $end + && ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + ) + ) { + ++$start; + } + + return $start + 1 === $end + && $this->is_mysql_numeric_literal_token( $tokens[ $start ] ) + && 0.0 === (float) $tokens[ $start ]->get_value(); + } + + /** + * Check whether a token is a numeric literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a numeric literal. + */ + private function is_mysql_numeric_literal_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + /** * Check whether a token is a string literal. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 210ef4c9c..74c4fbf26 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1540,6 +1540,165 @@ public function test_text_columns_preserve_lexical_string_comparisons(): void { $this->assertCount( 1, $meta_rows ); $this->assertSame( '1', $meta_rows[0]->meta_id ); $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $like_rows = $driver->query( "SELECT meta_id FROM wptests_postmeta WHERE meta_value LIKE '10%' ORDER BY meta_id" ); + + $this->assertCount( 2, $like_rows ); + $this->assertSame( '1', $like_rows[0]->meta_id ); + $this->assertSame( '2', $like_rows[1]->meta_id ); + $this->assertStringContainsString( "meta_value LIKE '10%'", $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests text metadata columns use MySQL numeric coercion when compared with numeric literals. + */ + public function test_text_metadata_numeric_literal_comparisons_use_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'score', '100')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'score', '')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'score', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (4, 'score', '20abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (5, 'other', '1')" ); + + $rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND meta_value < 50 ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '4' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( $meta_value_cast_sql . ' < 50', $sql ); + $this->assertStringContainsString( "meta_key = 'score'", $sql ); + + $mirrored_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND 50 > meta_value ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '4' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $mirrored_rows + ) + ); + $this->assertStringContainsString( '50 > ' . $meta_value_cast_sql, $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests text metadata columns use MySQL numeric coercion for ORDER BY column + 0. + */ + public function test_text_metadata_plus_zero_order_by_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'score', '10')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'score', '2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'score', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (4, 'score', '')" ); + + $rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' ORDER BY meta_value+0 ASC, post_id ASC" + ); + + $this->assertSame( + array( '3', '4', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' ASC, post_id ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests DISTINCT ORDER BY rewrites keep numeric metadata ordering safe. + */ + public function test_distinct_text_metadata_plus_zero_order_by_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_termmeta ( + `meta_id` bigint(20) unsigned NOT NULL, + `term_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (1, 'one')" ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (2, 'two')" ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (3, 'three')" ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (1, 1, 'score', '10')" + ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (2, 2, 'score', '2')" + ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (3, 3, 'score', 'abc')" + ); + + $rows = $driver->query( + "SELECT DISTINCT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_termmeta ON ( t.term_id = wptests_termmeta.term_id ) + WHERE wptests_termmeta.meta_key = 'score' + ORDER BY wptests_termmeta.meta_value+0 ASC" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->term_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'wptests_termmeta.meta_value' ); + $this->assertStringContainsString( + 'MIN(' . $meta_value_cast_sql . ') AS "__wp_pg_order_0"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); } /** From 2e5ec28cdd90dab534c4518b93c8b696d6d2143e Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 10 Jun 2026 23:27:35 +0000 Subject: [PATCH 060/142] Preserve decimal metadata numeric coercion --- .../postgresql/class-wp-postgresql-driver.php | 59 ++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 84 +++++++++++++++++-- 2 files changed, 126 insertions(+), 17 deletions(-) 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 index 4a0fe37f1..088f5df85 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3814,6 +3814,7 @@ private function append_non_strict_dml_defaults_for_omitted_columns( string $tab $columns[] = $column_name; $values[] = $default_sql; + $supplied_columns[ strtolower( $column_name ) ] = true; } } @@ -5158,18 +5159,18 @@ private function parse_mysql_select_order_by_items( ); $items[] = array( - 'expression_start' => $range['start'], - 'expression_end' => $expression_end, - 'sql' => $expression_sql['sql'], - 'direction' => $direction, + '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( + 'projection_index' => $this->find_mysql_projection_for_order_expression( $tokens, $range['start'], $expression_end, $projection_items ), - 'changed' => $expression_sql['changed'], + 'changed' => $expression_sql['changed'], ); } @@ -6613,7 +6614,7 @@ private function translate_mysql_select_statement_with_integer_string_coercion( } $first_clause_position = min( array_filter( array( $where_position, $order_position ), 'is_int' ) ); - $from_position = $this->find_top_level_mysql_token( + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, @@ -7154,7 +7155,7 @@ private function translate_mysql_text_column_numeric_comparison_to_postgresql( return array( 'sql' => sprintf( '%s %s %s', - $this->get_postgresql_mysql_integer_cast_sql( + $this->get_postgresql_mysql_numeric_cast_sql( $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) ), $tokens[ $reference['end'] ]->get_bytes(), @@ -7184,7 +7185,7 @@ private function translate_mysql_text_column_numeric_comparison_to_postgresql( '%s %s %s', $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ), $tokens[ $literal['end'] ]->get_bytes(), - $this->get_postgresql_mysql_integer_cast_sql( + $this->get_postgresql_mysql_numeric_cast_sql( $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) ) ), @@ -7220,7 +7221,7 @@ private function translate_mysql_text_column_numeric_addition_to_postgresql( && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) ) { return array( - 'sql' => $this->get_postgresql_mysql_integer_cast_sql( + 'sql' => $this->get_postgresql_mysql_numeric_cast_sql( $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) ), 'position' => $literal['end'] - 1, @@ -7244,7 +7245,7 @@ private function translate_mysql_text_column_numeric_addition_to_postgresql( } return array( - 'sql' => $this->get_postgresql_mysql_integer_cast_sql( + 'sql' => $this->get_postgresql_mysql_numeric_cast_sql( $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) ), 'position' => $reference['end'] - 1, @@ -8213,6 +8214,42 @@ private function get_postgresql_mysql_integer_cast_sql( string $expression_sql ) ); } + /** + * Get PostgreSQL SQL for MySQL-compatible decimal text coercion. + * + * MySQL text values in numeric expression contexts use the leading numeric + * prefix, including decimal and exponent forms, or zero when no prefix exists. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_numeric_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $substring_sql = array(); + $numeric_patterns = array( + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[.][0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*', + '^[[:space:]]*[+-]?[.][0-9]+', + '^[[:space:]]*[+-]?[0-9]+', + ); + + foreach ( $numeric_patterns as $pattern ) { + $substring_sql[] = sprintf( + 'SUBSTRING(%1$s, %2$s)', + $expression_text_sql, + $this->connection->quote( $pattern ) + ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(%2$s, \'0\') AS numeric) END', + $expression_text_sql, + implode( ', ', $substring_sql ) + ); + } + /** * Get token bounds for a supported MySQL integer CAST expression. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 74c4fbf26..8ff476d2c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -179,12 +179,12 @@ public function test_strict_insert_does_not_append_omitted_not_null_defaults(): )' ); $driver->store_mysql_schema_metadata( - "CREATE TABLE wptests_comments ( + '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' ); @@ -1583,7 +1583,7 @@ static function ( $row ): string { ) ); - $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); $sql = $driver->get_last_postgresql_queries()[0]['sql']; $this->assertStringContainsString( $meta_value_cast_sql . ' < 50', $sql ); $this->assertStringContainsString( "meta_key = 'score'", $sql ); @@ -1602,6 +1602,24 @@ static function ( $row ): string { ) ); $this->assertStringContainsString( '50 > ' . $meta_value_cast_sql, $driver->get_last_postgresql_queries()[0]['sql'] ); + + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (6, 'score', '1.7')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (7, 'score', '1.2')" ); + + $decimal_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND meta_value < 1.5 ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '7' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $decimal_rows + ) + ); + $this->assertStringContainsString( $meta_value_cast_sql . ' < 1.5', $driver->get_last_postgresql_queries()[0]['sql'] ); } /** @@ -1621,6 +1639,9 @@ public function test_text_metadata_plus_zero_order_by_uses_mysql_numeric_coercio $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'score', '2')" ); $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'score', 'abc')" ); $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (4, 'score', '')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (5, 'decimal', '1.7')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (6, 'decimal', '1.2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (7, 'decimal', '2')" ); $rows = $driver->query( "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' ORDER BY meta_value+0 ASC, post_id ASC" @@ -1636,7 +1657,25 @@ static function ( $row ): string { ) ); - $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' ASC, post_id ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $decimal_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'decimal' ORDER BY meta_value+0 ASC, post_id ASC" + ); + + $this->assertSame( + array( '6', '5', '7' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $decimal_rows + ) + ); $this->assertStringContainsString( 'ORDER BY ' . $meta_value_cast_sql . ' ASC, post_id ASC', $driver->get_last_postgresql_queries()[0]['sql'] @@ -1694,7 +1733,7 @@ static function ( $row ): string { ) ); - $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'wptests_termmeta.meta_value' ); + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'wptests_termmeta.meta_value' ); $this->assertStringContainsString( 'MIN(' . $meta_value_cast_sql . ') AS "__wp_pg_order_0"', $driver->get_last_postgresql_queries()[0]['sql'] @@ -3177,7 +3216,7 @@ public function test_information_schema_tables_site_health_single_quoted_alias_p * Tests unsupported information_schema.TABLES shapes do not enter the Site Health translator. */ public function test_information_schema_tables_site_health_unsupported_shapes_fail_closed(): void { - $driver = $this->create_driver(); + $driver = $this->create_driver(); $queries = array( "SELECT COUNT(*) AS 'rows' FROM information_schema.TABLES @@ -3550,6 +3589,39 @@ private function get_expected_mysql_integer_cast_sql( string $expression_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. * From 9bcd460b48004df45de1a89e87d9e7a7ce719078 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 01:48:07 +0000 Subject: [PATCH 061/142] Improve PostgreSQL SELECT compatibility --- .../postgresql/class-wp-postgresql-driver.php | 825 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 311 +++++++ 2 files changed, 1106 insertions(+), 30 deletions(-) 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 index 088f5df85..102f12bbf 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -5426,13 +5426,18 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer return null; } - $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $projection_start, $statement_end ); $select_end = $limit_position ?? $statement_end; if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { return null; } - $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $projection_start, $select_end ); if ( null === $order_position || ! isset( $tokens[ $order_position + 1 ] ) @@ -5445,7 +5450,7 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer if ( $this->contains_top_level_mysql_token( $tokens, - 1, + $projection_start, $select_end, array( WP_MySQL_Lexer::DISTINCT_SYMBOL, @@ -5454,7 +5459,6 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer WP_MySQL_Lexer::INTO_SYMBOL, WP_MySQL_Lexer::LOCK_SYMBOL, WP_MySQL_Lexer::PROCEDURE_SYMBOL, - WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, WP_MySQL_Lexer::UNION_SYMBOL, ) ) @@ -5462,10 +5466,11 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer return null; } - $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, 1, $order_position ); + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $projection_start, $order_position ); if ( null === $group_position ) { return $this->translate_strict_aggregate_only_order_by_query( $tokens, + $projection_start, $order_position, $limit_position, $statement_end @@ -5482,6 +5487,7 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer return $this->translate_strict_grouped_order_by_query( $tokens, + $projection_start, $group_position, $order_position, $limit_position, @@ -5493,6 +5499,7 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer * Drop ORDER BY from scalar COUNT-only aggregate queries. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. * @param int $order_position ORDER token position. * @param int|null $limit_position LIMIT token position, or null. * @param int $statement_end Final statement token position, exclusive. @@ -5500,20 +5507,21 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer */ private function translate_strict_aggregate_only_order_by_query( array $tokens, + int $projection_start, int $order_position, ?int $limit_position, int $statement_end ): ?string { - $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $order_position ); - if ( null === $from_position || 1 === $from_position ) { + $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, 1, $from_position ) ) { + if ( ! $this->is_mysql_count_only_projection( $tokens, $projection_start, $from_position ) ) { return null; } - $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $order_position ); + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $order_position ); if ( null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } @@ -5525,6 +5533,7 @@ private function translate_strict_aggregate_only_order_by_query( * Translate targeted grouped ORDER BY expressions to aggregate-safe forms. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. * @param int $group_position GROUP token position. * @param int $order_position ORDER token position. * @param int|null $limit_position LIMIT token position, or null. @@ -5533,17 +5542,18 @@ private function translate_strict_aggregate_only_order_by_query( */ 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 ): ?string { - $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $group_position ); - if ( null === $from_position || 1 === $from_position ) { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $group_position ); + if ( null === $from_position || $projection_start === $from_position ) { return null; } - $projection_items = $this->parse_mysql_select_projection_items( $tokens, 1, $from_position ); + $projection_items = $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ); if ( null === $projection_items ) { return null; } @@ -5553,12 +5563,24 @@ private function translate_strict_grouped_order_by_query( return null; } + $scope_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $group_position + ) ?? $group_position; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + $select_end = $limit_position ?? $statement_end; $order_items = $this->parse_mysql_select_order_by_items( $tokens, $order_position + 2, $select_end, - $projection_items + $projection_items, + $scope ); if ( null === $order_items ) { return null; @@ -5566,7 +5588,8 @@ private function translate_strict_grouped_order_by_query( $archive_date_expression = $this->get_mysql_archive_grouped_date_expression_bounds( $tokens, $group_items ); $is_comment_id_group = $this->is_mysql_comment_id_grouped_select_shape( $tokens, $projection_items, $group_items ); - if ( null === $archive_date_expression && ! $is_comment_id_group ) { + $is_post_id_group = $this->is_mysql_post_id_grouped_select_shape( $tokens, $projection_items, $group_items ); + if ( null === $archive_date_expression && ! $is_comment_id_group && ! $is_post_id_group ) { return null; } @@ -5591,8 +5614,14 @@ private function translate_strict_grouped_order_by_query( } if ( - $is_comment_id_group - && $this->is_mysql_comment_id_grouped_order_expression( $tokens, $order_item ) + ( + $is_comment_id_group + && $this->is_mysql_comment_id_grouped_order_expression( $tokens, $order_item ) + ) + || ( + $is_post_id_group + && $this->is_mysql_post_id_grouped_order_expression( $tokens, $order_item ) + ) ) { $order_sql[] = $this->get_strict_grouped_aggregate_order_sql( $order_item ); $rewritten = true; @@ -5606,7 +5635,30 @@ private function translate_strict_grouped_order_by_query( return null; } - $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $order_position ) + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $projection_start, $group_position ); + if ( null !== $where_position ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $group_position, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $group_position, + 'sql' => $where_sql['sql'], + ); + } + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $projection_start, + $order_position, + $replacements + ) . ' ORDER BY ' . implode( ', ', $order_sql ); if ( null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); @@ -5707,19 +5759,22 @@ private function is_mysql_count_aggregate_expression( array $tokens, int $start, } /** - * Get the shared post_date expression from YEAR(post_date), MONTH(post_date) grouping. + * Get the shared post_date expression from archive date grouping. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param array $group_items Parsed GROUP BY item ranges. * @return array{start: int, end: int}|null Shared post_date expression bounds, or null. */ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens, array $group_items ): ?array { - if ( 2 !== count( $group_items ) ) { + $group_count = count( $group_items ); + if ( 1 > $group_count || 3 < $group_count ) { return null; } - $year_expression = null; - $month_expression = null; + $year_expression = null; + $month_expression = null; + $dayofmonth_expression = null; + $supported_expressions = 0; foreach ( $group_items as $group_item ) { $expression = $this->get_mysql_extract_argument_expression_bounds( $tokens, @@ -5729,6 +5784,7 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens ); if ( null !== $expression ) { $year_expression = $expression; + ++$supported_expressions; continue; } @@ -5740,18 +5796,52 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens ); if ( null !== $expression ) { $month_expression = $expression; + ++$supported_expressions; + continue; + } + + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'DAY' + ); + if ( null !== $expression ) { + $dayofmonth_expression = $expression; + ++$supported_expressions; } } if ( - null === $year_expression - || null === $month_expression - || ! $this->are_mysql_token_ranges_equivalent( - $tokens, - $year_expression['start'], - $year_expression['end'], - $month_expression['start'], - $month_expression['end'] + $group_count !== $supported_expressions + || null === $year_expression + || ( + 2 <= $group_count + && null === $month_expression + ) + || ( + 3 === $group_count + && null === $dayofmonth_expression + ) + || ( + null !== $month_expression + && ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $month_expression['start'], + $month_expression['end'] + ) + ) + || ( + null !== $dayofmonth_expression + && ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $dayofmonth_expression['start'], + $dayofmonth_expression['end'] + ) ) || ! $this->is_mysql_column_reference_expression( $tokens, @@ -5910,6 +6000,154 @@ private function is_mysql_comment_id_grouped_order_expression( array $tokens, ar ); } + /** + * Check whether a SELECT is grouped by the selected posts.ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether this is the supported post ID grouped shape. + */ + private function is_mysql_post_id_grouped_select_shape( array $tokens, array $projection_items, array $group_items ): bool { + return 1 === count( $projection_items ) + && 1 === count( $group_items ) + && $this->is_mysql_post_id_expression( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'] + ) + && $this->is_mysql_post_id_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ); + } + + /** + * Check whether an expression references posts.ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is posts.ID. + */ + private function is_mysql_post_id_expression( array $tokens, int $start, int $end ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, 'ID', 'posts', true ); + } + + /** + * Check whether a grouped post ID ORDER BY expression can be aggregated. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_post_id_grouped_order_expression( array $tokens, array $order_item ): bool { + if ( + $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date', + 'posts', + false + ) + || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date_gmt', + 'posts', + false + ) + || $this->is_mysql_qualified_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'meta_value' + ) + ) { + return true; + } + + return $this->is_mysql_meta_value_cast_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ) || $this->is_mysql_meta_value_plus_zero_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + } + + /** + * Check whether an expression is a supported metadata value CAST. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression casts a qualified meta_value reference. + */ + private function is_mysql_meta_value_cast_expression( array $tokens, int $start, int $end ): bool { + $cast_bounds = $this->get_mysql_character_cast_bounds( $tokens, $start, $end ); + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_integer_cast_bounds( $tokens, $start, $end ); + } + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_decimal_cast_bounds( $tokens, $start, $end ); + } + + return null !== $cast_bounds + && $cast_bounds['close'] + 1 === $end + && $this->is_mysql_qualified_column_reference_expression( + $tokens, + $cast_bounds['expression_start'], + $cast_bounds['expression_end'], + 'meta_value' + ); + } + + /** + * Check whether an expression is metadata value plus zero numeric ordering. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression numerically orders meta_value. + */ + private function is_mysql_meta_value_plus_zero_expression( array $tokens, int $start, int $end ): bool { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null !== $reference + && null !== $reference['qualifier'] + && isset( $tokens[ $reference['end'] ] ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $reference['end'] ]->id + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + return null !== $literal + && $literal['end'] === $end + && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + && $this->is_mysql_qualified_column_reference_expression( $tokens, $start, $reference['end'], 'meta_value' ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( + null === $literal + || ! $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + || ! isset( $tokens[ $literal['end'] ] ) + || WP_MySQL_Lexer::PLUS_OPERATOR !== $tokens[ $literal['end'] ]->id + ) { + return false; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $literal['end'] + 1, $end ); + return null !== $reference + && $reference['end'] === $end + && null !== $reference['qualifier'] + && $this->is_mysql_qualified_column_reference_expression( $tokens, $reference['start'], $reference['end'], 'meta_value' ); + } + /** * Check whether an ORDER BY expression is already valid for the GROUP BY. * @@ -6898,6 +7136,24 @@ private function translate_mysql_predicate_token_sequence_to_postgresql( ) { $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); if ( null !== $after_subquery ) { + $translated_subquery = $this->translate_mysql_parenthesized_select_predicate_to_postgresql( + $tokens, + $position, + $after_subquery, + $scope + ); + if ( null !== $translated_subquery ) { + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = $translated_subquery['sql']; + $segment_start = $translated_subquery['position'] + 1; + $position = $translated_subquery['position']; + $changed = true; + continue; + } + $position = $after_subquery - 1; continue; } @@ -6955,6 +7211,24 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( int $end, array $scope ): ?array { + $truthiness = $this->translate_mysql_numeric_literal_truthiness_predicate_to_postgresql( + $tokens, + $position, + $end + ); + if ( null !== $truthiness ) { + return $truthiness; + } + + $decimal_like = $this->translate_mysql_decimal_cast_like_predicate_to_postgresql( + $tokens, + $position, + $end + ); + if ( null !== $decimal_like ) { + return $decimal_like; + } + $in_predicate = $this->translate_mysql_integer_column_string_in_predicate_to_postgresql( $tokens, $position, @@ -6975,7 +7249,17 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( return $comparison; } - return $this->translate_mysql_text_column_numeric_comparison_to_postgresql( + $numeric_comparison = $this->translate_mysql_text_column_numeric_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $numeric_comparison ) { + return $numeric_comparison; + } + + return $this->translate_mysql_metadata_column_reference_to_postgresql( $tokens, $position, $end, @@ -6983,6 +7267,487 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( ); } + /** + * Translate a parenthesized SELECT predicate with inner and outer metadata scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Opening parenthesis position. + * @param int $after_subquery Position after the closing parenthesis. + * @param array $outer_scope Outer statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unchanged/unsupported. + */ + private function translate_mysql_parenthesized_select_predicate_to_postgresql( + array $tokens, + int $position, + int $after_subquery, + array $outer_scope + ): ?array { + $select_position = $position + 1; + $statement_end = $after_subquery - 1; + if ( + ! isset( $tokens[ $select_position ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_position ]->id + ) { + return null; + } + + $projection_start = $select_position + 1; + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position ) { + return null; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $inner_scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $from_end ); + if ( null === $inner_scope ) { + return null; + } + + $scope = $this->merge_mysql_inner_and_outer_scopes( $inner_scope, $outer_scope ); + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $projection_start, + $statement_end + ); + if ( null !== $where_position ) { + $where_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $where_position + 1, + $statement_end + ) ?? $statement_end; + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $where_end, + 'sql' => $where_sql['sql'], + ); + } + } + + if ( empty( $replacements ) ) { + return null; + } + + return array( + 'sql' => '(' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $select_position, $statement_end, $replacements ) . ')', + 'position' => $after_subquery - 1, + ); + } + + /** + * Merge SELECT scopes so inner aliases shadow correlated outer aliases. + * + * @param array $inner_scope Inner SELECT table scope. + * @param array $outer_scope Outer SELECT table scope. + * @return array Combined scope. + */ + private function merge_mysql_inner_and_outer_scopes( array $inner_scope, array $outer_scope ): array { + $scope = $inner_scope; + foreach ( $outer_scope['aliases'] as $alias => $table ) { + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + $scope['aliases'][ $alias ] = $table; + } + } + + if ( ! empty( $outer_scope['unknown'] ) ) { + $scope['unknown'] = true; + } + + return $scope; + } + + /** + * Translate a numeric literal used as a standalone boolean predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_numeric_literal_truthiness_predicate_to_postgresql( + array $tokens, + int $position, + int $end + ): ?array { + $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $end ); + if ( null === $literal || ! $this->is_mysql_boolean_predicate_literal_context( $tokens, $literal['start'], $literal['end'], $end ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '(%s <> 0)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ) + ), + 'position' => $literal['end'] - 1, + ); + } + + /** + * Check whether a numeric literal is a standalone boolean predicate operand. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @param int $limit Final predicate token position, exclusive. + * @return bool Whether the literal is in predicate truthiness context. + */ + private function is_mysql_boolean_predicate_literal_context( array $tokens, int $start, int $end, int $limit ): bool { + $previous_token_id = $tokens[ $start - 1 ]->id ?? null; + $next_token_id = $tokens[ $end ]->id ?? null; + + $left_boundary = 0 === $start + || $this->is_mysql_boolean_predicate_left_boundary_token_id( $previous_token_id ) + || ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $start - 2 ]->id ?? null ) + ); + if ( ! $left_boundary ) { + return false; + } + + return $end >= $limit || in_array( + $next_token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Check whether a token can precede a standalone boolean predicate operand. + * + * @param int|null $token_id MySQL token ID. + * @return bool Whether the token is a boolean left boundary. + */ + private function is_mysql_boolean_predicate_left_boundary_token_id( ?int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::NOT_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Translate DECIMAL casts used with string-pattern operators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_decimal_cast_like_predicate_to_postgresql( + array $tokens, + int $position, + int $end + ): ?array { + $cast_bounds = $this->get_mysql_decimal_cast_bounds( $tokens, $position, $end ); + if ( null === $cast_bounds ) { + return null; + } + + $operator_position = $cast_bounds['close'] + 1; + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + $operator_position += 1; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $operator_position ]->id + ) { + return null; + } + + $pattern_end = $this->get_mysql_like_pattern_end( $tokens, $operator_position + 1, $end ); + if ( null === $pattern_end ) { + return null; + } + + return array( + 'sql' => sprintf( + 'CAST(%s AS text)%s LIKE %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $cast_bounds['close'] + 1 ), + $not_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $operator_position + 1, $pattern_end ) + ), + 'position' => $pattern_end - 1, + ); + } + + /** + * Get the end of a simple LIKE pattern expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Pattern token position. + * @param int $end Final predicate token position, exclusive. + * @return int|null Pattern end position, exclusive. + */ + private function get_mysql_like_pattern_end( array $tokens, int $position, int $end ): ?int { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + $pattern_end = $position + 1; + if ( + isset( $tokens[ $pattern_end ], $tokens[ $pattern_end + 1 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_end ]->id + && $pattern_end + 1 < $end + ) { + $pattern_end += 2; + } + + return $pattern_end; + } + + /** + * Translate metadata-backed qualified column casing when MySQL casing differs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unchanged/unsupported. + */ + private function translate_mysql_metadata_column_reference_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference || null === $reference['qualifier'] ) { + return null; + } + + $resolved_column = $this->get_mysql_column_name_for_reference( $reference, $scope ); + if ( null === $resolved_column || $resolved_column === $reference['column'] ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s.%s', + $this->translate_mysql_token_to_postgresql( $tokens[ $reference['start'] ], $tokens[ $reference['start'] + 1 ] ?? null ), + $this->translate_mysql_identifier_value_to_postgresql( $resolved_column ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Resolve the stored MySQL column name for a scoped column reference. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null Stored column name, or null when missing/ambiguous. + */ + private function get_mysql_column_name_for_reference( array $reference, array $scope ): ?string { + if ( null === $reference['qualifier'] ) { + return null; + } + + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + return $this->get_mysql_table_column_name( $table['schema'], $table['table'], $reference['column'] ); + } + + /** + * Get the metadata-backed stored column name for a MySQL column reference. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Referenced column name. + * @return string|null Stored column name, or null when no safe casing rewrite exists. + */ + private function get_mysql_table_column_name( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND column_name = ? + LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $stored_column_name = $stmt->fetchColumn(); + if ( false !== $stored_column_name ) { + return (string) $stored_column_name; + } + + $lowercase_column_name = strtolower( $column_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND column_name = ? + LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $lowercase_column_name ) + ); + + $stored_column_name = $stmt->fetchColumn(); + return false === $stored_column_name ? null : (string) $stored_column_name; + } + + /** + * Translate a stored identifier value for PostgreSQL. + * + * @param string $identifier Identifier value. + * @return string PostgreSQL identifier SQL. + */ + private function translate_mysql_identifier_value_to_postgresql( string $identifier ): string { + return $this->should_quote_bare_mysql_identifier( $identifier ) + ? $this->connection->quote_identifier( $identifier ) + : $identifier; + } + + /** + * Get token bounds for a DECIMAL/NUMERIC CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_decimal_cast_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_decimal_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL DECIMAL/NUMERIC. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_decimal_cast_type( array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ] ) + || ! in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::DECIMAL_SYMBOL, + WP_MySQL_Lexer::NUMERIC_SYMBOL, + ), + true + ) + ) { + return false; + } + + if ( $start + 1 === $end ) { + return true; + } + + return isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ) === $end; + } + /** * Translate an integer-column IN list containing string literals. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 8ff476d2c..288c68603 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2230,6 +2230,265 @@ public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_ord $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); } + /** + * Tests SQL_CALC_FOUND_ROWS grouped postmeta queries aggregate sort expressions. + */ + public function test_sql_calc_grouped_postmeta_order_by_uses_aggregate_sort_expressions(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (3, 'post', 'publish', '2024-01-03 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'foo', 'b')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'foo', 'a')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'foo', 'a')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'bar', '5')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'bar', '2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'bar', '9')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + INNER JOIN wptests_postmeta AS mt1 ON ( wptests_posts.ID = mt1.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'foo' + AND mt1.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS CHAR) ASC, CAST(mt1.meta_value AS UNSIGNED) DESC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); + $this->assertStringContainsString( 'ORDER BY MIN(CAST(wptests_postmeta.meta_value AS text)) ASC', $sql ); + $this->assertStringContainsString( 'MAX(' . $this->get_expected_mysql_integer_cast_sql( 'mt1.meta_value' ) . ') DESC', $sql ); + + $numeric_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY wptests_postmeta.meta_value+0 ASC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '2', '1', '3' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $numeric_rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY MIN(' . $this->get_expected_mysql_numeric_cast_sql( 'wptests_postmeta.meta_value' ) . ') ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $decimal_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS DECIMAL(10, 2)) DESC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '3', '1', '2' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $decimal_rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY MAX(CAST (wptests_postmeta.meta_value AS DECIMAL (10, 2))) DESC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests 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\')' ); + + $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'] ); + + $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 grouped DISTINCT ORDER BY shapes fail closed for later SELECT passes. */ @@ -2310,6 +2569,58 @@ public function test_grouped_date_archive_order_by_uses_aggregate_sort_expressio $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 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. */ From 5dde0c04ac1773aa648a2718cf4a0c677b8000fe Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 02:13:40 +0000 Subject: [PATCH 062/142] Preserve BETWEEN bounds in truthiness rewrite --- .../postgresql/class-wp-postgresql-driver.php | 86 +++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 32 +++++++ 2 files changed, 118 insertions(+) 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 index 102f12bbf..6b690ff69 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -7433,6 +7433,10 @@ private function translate_mysql_numeric_literal_truthiness_predicate_to_postgre * @return bool Whether the literal is in predicate truthiness context. */ private function is_mysql_boolean_predicate_literal_context( array $tokens, int $start, int $end, int $limit ): bool { + if ( $this->is_mysql_between_bound_literal_context( $tokens, $start ) ) { + return false; + } + $previous_token_id = $tokens[ $start - 1 ]->id ?? null; $next_token_id = $tokens[ $end ]->id ?? null; @@ -7458,6 +7462,88 @@ private function is_mysql_boolean_predicate_literal_context( array $tokens, int ); } + /** + * Check whether a numeric literal belongs to a BETWEEN range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @return bool Whether the literal is a BETWEEN bound. + */ + private function is_mysql_between_bound_literal_context( array $tokens, int $start ): bool { + $previous_token_id = $tokens[ $start - 1 ]->id ?? null; + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $previous_token_id ) { + return true; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && WP_MySQL_Lexer::BETWEEN_SYMBOL === ( $tokens[ $start - 2 ]->id ?? null ) + ) { + return true; + } + + $and_position = null; + if ( WP_MySQL_Lexer::AND_SYMBOL === $previous_token_id ) { + $and_position = $start - 1; + } elseif ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && WP_MySQL_Lexer::AND_SYMBOL === ( $tokens[ $start - 2 ]->id ?? null ) + ) { + $and_position = $start - 2; + } + + return null !== $and_position && $this->is_mysql_between_upper_bound_separator( $tokens, $and_position ); + } + + /** + * Check whether an AND token separates the lower and upper BETWEEN bounds. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $and_position Candidate AND token position. + * @return bool Whether the AND token belongs to BETWEEN. + */ + private function is_mysql_between_upper_bound_separator( array $tokens, int $and_position ): bool { + if ( ! isset( $tokens[ $and_position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $and_position ]->id ) { + return false; + } + + $depth = 0; + for ( $i = $and_position - 1; $i >= 0; $i-- ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return false; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $tokens[ $i ]->id ) { + return true; + } + + if ( + $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $i ]->id ) + || WP_MySQL_Lexer::HAVING_SYMBOL === $tokens[ $i ]->id + || WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $i ]->id + ) { + return false; + } + } + + return false; + } + /** * Check whether a token can precede a standalone boolean predicate operand. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 288c68603..79ae5c057 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2352,6 +2352,11 @@ public function test_numeric_literal_predicates_use_mysql_truthiness_without_cha $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 @@ -2364,6 +2369,33 @@ public function test_numeric_literal_predicates_use_mysql_truthiness_without_cha $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'] ); From c8e9646cdcc37a773f012fdea3736832a4aa66b1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 04:45:51 +0000 Subject: [PATCH 063/142] Add PostgreSQL date function compatibility --- .../postgresql/class-wp-postgresql-driver.php | 472 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 232 +++++++++ 2 files changed, 701 insertions(+), 3 deletions(-) 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 index 6b690ff69..63c03b35a 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -5427,6 +5427,10 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer } $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $projection_start ]->id ) { ++$projection_start; } @@ -5453,7 +5457,6 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer $projection_start, $select_end, array( - WP_MySQL_Lexer::DISTINCT_SYMBOL, WP_MySQL_Lexer::FOR_SYMBOL, WP_MySQL_Lexer::HAVING_SYMBOL, WP_MySQL_Lexer::INTO_SYMBOL, @@ -5637,6 +5640,17 @@ private function translate_strict_grouped_order_by_query( $replacements = array(); $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $projection_start, $group_position ); + if ( null !== $archive_date_expression ) { + $replacements = array_merge( + $replacements, + $this->get_mysql_archive_grouped_projection_replacements( + $tokens, + $projection_items, + $archive_date_expression + ) + ); + } + if ( null !== $where_position ) { $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( $tokens, @@ -5667,6 +5681,61 @@ private function translate_strict_grouped_order_by_query( return $sql; } + /** + * Get projection replacements needed by grouped archive queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $archive_date_expression Shared date expression bounds. + * @return array Replacement ranges. + */ + private function get_mysql_archive_grouped_projection_replacements( array $tokens, array $projection_items, array $archive_date_expression ): array { + $replacements = array(); + + foreach ( $projection_items as $projection_item ) { + $bounds = $this->get_mysql_date_format_bounds( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'] + ); + if ( + null === $bounds + || '%Y-%m-%d' !== $bounds['format'] + || $bounds['close'] + 1 !== $projection_item['expression_end'] + || ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $bounds['expression_start'], + $bounds['expression_end'] + ) + ) { + continue; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $sql = $this->get_postgresql_mysql_date_format_sql( + $bounds['format'], + sprintf( 'MAX(%s)', $expression_sql ) + ); + if ( null === $sql ) { + continue; + } + + $replacements[] = array( + 'start' => $projection_item['expression_start'], + 'end' => $projection_item['expression_end'], + 'sql' => $sql, + ); + } + + return $replacements; + } + /** * Check whether a projection is exactly one COUNT aggregate. * @@ -5772,6 +5841,7 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens } $year_expression = null; + $week_expression = null; $month_expression = null; $dayofmonth_expression = null; $supported_expressions = 0; @@ -5788,6 +5858,17 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens continue; } + $expression = $this->get_mysql_week_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'] + ); + if ( null !== $expression ) { + $week_expression = $expression; + ++$supported_expressions; + continue; + } + $expression = $this->get_mysql_extract_argument_expression_bounds( $tokens, $group_item['start'], @@ -5815,7 +5896,27 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens if ( $group_count !== $supported_expressions || null === $year_expression - || ( + ) { + return null; + } + + if ( null !== $week_expression ) { + if ( + 2 !== $group_count + || null !== $month_expression + || null !== $dayofmonth_expression + || ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $week_expression['start'], + $week_expression['end'] + ) + ) { + return null; + } + } elseif ( + ( 2 <= $group_count && null === $month_expression ) @@ -5843,7 +5944,12 @@ private function get_mysql_archive_grouped_date_expression_bounds( array $tokens $dayofmonth_expression['end'] ) ) - || ! $this->is_mysql_column_reference_expression( + ) { + return null; + } + + if ( + ! $this->is_mysql_column_reference_expression( $tokens, $year_expression['start'], $year_expression['end'], @@ -5888,6 +5994,34 @@ private function get_mysql_extract_argument_expression_bounds( array $tokens, in ); } + /** + * Get the argument expression for a supported WEEK() function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return array{start: int, end: int}|null Argument bounds, or null. + */ + private function get_mysql_week_argument_expression_bounds( array $tokens, int $start, int $end ): ?array { + $expression_bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $bounds = $this->get_mysql_week_function_bounds( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( + null === $bounds + || $bounds['close'] + 1 !== $expression_bounds['end'] + ) { + return null; + } + + return array( + 'start' => $bounds['expression_start'], + 'end' => $bounds['expression_end'], + ); + } + /** * Check whether ORDER BY references the archive post_date expression. * @@ -8880,6 +9014,15 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_week_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_weekday_index_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_format_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); } @@ -9615,6 +9758,317 @@ private function get_postgresql_mysql_interval_value_sql( string $value_sql ): s return sprintf( 'CAST(%s AS double precision)', $this->get_postgresql_mysql_integer_cast_sql( $value_sql ) ); } + /** + * Translate MySQL WEEK(expr, 1) calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_week_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_week_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_week_mode_one_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL WEEK(expr, mode) calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_week_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::WEEK_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( + null === $arguments + || 2 !== count( $arguments ) + || ! $this->is_mysql_week_mode_one_argument( $tokens, $arguments[1]['start'], $arguments[1]['end'] ) + ) { + return null; + } + + return array( + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'close' => $after_close - 1, + ); + } + + /** + * Check whether a WEEK() mode argument is the supported MySQL mode 1. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First mode token. + * @param int $end Final mode token, exclusive. + * @return bool Whether the mode is supported. + */ + private function is_mysql_week_mode_one_argument( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::INT_NUMBER === $tokens[ $start ]->id + && '1' === $tokens[ $start ]->get_value(); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 1). + * + * MySQL mode 1 is Monday-first and returns week numbers in the given year, + * using 0 for dates before that year's first ISO-like week. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_week_mode_one_sql( string $expression_sql ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( + "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + $year_start_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Translate MySQL DAYOFWEEK(expr) and WEEKDAY(expr) calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_weekday_index_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_weekday_index_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_weekday_index_sql( $bounds['function'], $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL weekday index functions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{function: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_weekday_index_function_bounds( array $tokens, int $position, int $end ): ?array { + $function_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $function_name ) { + return null; + } + + $function_name = strtolower( $function_name ); + if ( 'dayofweek' !== $function_name && 'weekday' !== $function_name ) { + return null; + } + + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, $function_name ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return null; + } + + return array( + 'function' => $function_name, + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'close' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for a MySQL weekday index function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_weekday_index_sql( string $function_name, string $expression_sql ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + + if ( 'dayofweek' === $function_name ) { + return sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ); + } + + return sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + } + + /** + * Translate supported MySQL DATE_FORMAT(expr, format) calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_format_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_format_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $sql = $this->get_postgresql_mysql_date_format_sql( $bounds['format'], $expression_sql ); + if ( null === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL DATE_FORMAT(expr, format) calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{format: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_format_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'date_format' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( + null === $arguments + || 2 !== count( $arguments ) + || ! $this->is_mysql_string_literal_range( $tokens, $arguments[1]['start'], $arguments[1]['end'] ) + ) { + return null; + } + + return array( + 'format' => $tokens[ $arguments[1]['start'] ]->get_value(), + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'close' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for a supported MySQL DATE_FORMAT() format. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function get_postgresql_mysql_date_format_sql( string $format, string $expression_sql ): ?string { + switch ( $format ) { + case '%H.%i': + return $this->get_postgresql_mysql_date_format_hour_minute_sql( $expression_sql ); + + case '%Y-%m-%d': + return $this->get_postgresql_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + return null; + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_hour_minute_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $date_time_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql, + $date_time_pattern + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, %4$s) AS double precision) END', + $zero_date_condition, + $zero_date_format_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'HH24.MI' ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%Y-%m-%d'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_year_month_day_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN SUBSTRING(%2$s FROM 1 FOR 10) ELSE TO_CHAR(%3$s, %4$s) END', + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'YYYY-MM-DD' ) + ); + } + /** * Translate supported MySQL date/time extract functions to PostgreSQL. * @@ -10177,6 +10631,18 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_week_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_weekday_index_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_format_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $i, $end ) ) { return true; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 79ae5c057..d5339eedf 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2626,6 +2626,35 @@ public function test_grouped_year_archive_order_by_uses_aggregate_sort_expressio $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $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. */ @@ -2884,6 +2913,112 @@ public function test_mysql_date_time_extract_functions_guard_literal_zero_dates_ ); } + /** + * Tests MySQL WEEK and weekday index functions are translated for PostgreSQL. + */ + public function test_mysql_week_and_weekday_index_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT WEEK(post_date, 1) AS week_num, DAYOFWEEK(post_date) AS day_of_week, WEEKDAY(post_date) AS weekday_value FROM wptests_posts WHERE WEEK(post_date, 1) = 24 AND DAYOFWEEK(post_date) = 1 AND WEEKDAY(post_date) = 6'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' AS week_num, ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' AS day_of_week, ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' AS weekday_value FROM wptests_posts WHERE ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' = 24 AND ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' = 1 AND ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' = 6', + $sql + ); + $this->assertStringNotContainsString( 'WEEK(', $sql ); + $this->assertStringNotContainsString( 'DAYOFWEEK', $sql ); + $this->assertStringNotContainsString( 'WEEKDAY', $sql ); + } + + /** + * Tests lowercase MySQL date compatibility functions trigger translation. + */ + public function test_lowercase_mysql_date_compatibility_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT week(post_date, 1) AS week_num, dayofweek(post_date) AS day_of_week, weekday(post_date) AS weekday_value, date_format(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' AS week_num, ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' AS day_of_week, ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' AS weekday_value, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts', + $sql + ); + } + + /** + * Tests supported MySQL DATE_FORMAT calls are translated for PostgreSQL. + */ + public function test_mysql_date_format_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_FORMAT(post_date, '%H.%i') AS hour_minute, DATE_FORMAT(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts WHERE DATE_FORMAT(post_date, '%H.%i') >= 0.42"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' AS hour_minute, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts WHERE ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' >= 0.42', + $sql + ); + $this->assertStringContainsString( 'CAST(TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'HH24.MI') AS double precision)", $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'YYYY-MM-DD')", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests date compatibility function names inside string literals are not translated. + */ + public function test_mysql_date_compatibility_function_names_inside_literals_are_not_translated(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format", + $sql + ); + $this->assertStringNotContainsString( 'DATE_TRUNC', $sql ); + $this->assertStringNotContainsString( 'EXTRACT(DOW', $sql ); + $this->assertStringNotContainsString( 'TO_CHAR', $sql ); + } + + /** + * Tests generated WEEK, weekday, and DATE_FORMAT SQL guards zero-date timestamp casts. + */ + public function test_mysql_date_compatibility_functions_guard_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT WEEK('0000-00-00 00:00:00', 1) AS week_num, DAYOFWEEK('2020-00-15 13:05:00') AS day_of_week, WEEKDAY('2020-01-00 13:05:00') AS weekday_value, DATE_FORMAT('0000-00-00 13:05:00', '%H.%i') AS hour_minute, DATE_FORMAT('2020-00-15 13:05:00', '%Y-%m-%d') AS formatted_date"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-00-15 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-01-00 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN CAST(SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 12 FOR 2) || '.' || SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 15 FOR 2) AS double precision) ELSE 0 END", $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('2020-00-15 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-00-15 13:05:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-01-00 13:05:00' AS timestamp)", $sql ); + } + + /** + * Tests unsupported DATE_FORMAT specifiers remain unhandled. + */ + public function test_mysql_date_format_with_unsupported_specifier_remains_unhandled(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT(post_date, '%W') AS formatted_date" + ); + + $this->assertSame( + "SELECT DATE_FORMAT (post_date, '%W') AS formatted_date", + $sql + ); + } + /** * Tests representative WordPress date archive queries do not reach PostgreSQL with raw MySQL functions. */ @@ -3994,6 +4129,103 @@ private function get_expected_mysql_interval_value_sql( string $value_sql ): str return sprintf( 'CAST(%s AS double precision)', $this->get_expected_mysql_integer_cast_sql( $value_sql ) ); } + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 1). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_mode_one_sql( string $expression_sql ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( + "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + $year_start_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL weekday index function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_weekday_index_sql( string $function_name, string $expression_sql ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + + if ( 'dayofweek' === $function_name ) { + return sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ); + } + + return sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + } + + /** + * Get expected PostgreSQL SQL for a supported MySQL DATE_FORMAT() format. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_sql( string $format, string $expression_sql ): string { + if ( '%H.%i' === $format ) { + return $this->get_expected_mysql_date_format_hour_minute_sql( $expression_sql ); + } + + if ( '%Y-%m-%d' === $format ) { + return $this->get_expected_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + throw new InvalidArgumentException( 'Unsupported test date format.' ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_hour_minute_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, \'HH24.MI\') AS double precision) END', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $zero_date_format_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%Y-%m-%d'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_year_month_day_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN SUBSTRING(%2$s FROM 1 FOR 10) ELSE TO_CHAR(%3$s, \'YYYY-MM-DD\') END', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + /** * Get expected zero-date-safe PostgreSQL date/time extract SQL. * From 9b9d4ac117886a6b6f10b0dc83c8c37ff0d4c432 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 05:02:38 +0000 Subject: [PATCH 064/142] Preserve DISTINCT grouped archive semantics --- .../postgresql/class-wp-postgresql-driver.php | 178 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 20 ++ 2 files changed, 196 insertions(+), 2 deletions(-) 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 index 63c03b35a..032d94706 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -5427,7 +5427,9 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer } $projection_start = 1; + $has_distinct = false; if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + $has_distinct = true; ++$projection_start; } @@ -5494,7 +5496,8 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer $group_position, $order_position, $limit_position, - $statement_end + $statement_end, + $has_distinct ); } @@ -5541,6 +5544,7 @@ private function translate_strict_aggregate_only_order_by_query( * @param int $order_position ORDER token position. * @param int|null $limit_position LIMIT token position, or null. * @param int $statement_end Final statement token position, exclusive. + * @param bool $has_distinct Whether the original SELECT used DISTINCT. * @return string|null PostgreSQL query, or null when unsupported. */ private function translate_strict_grouped_order_by_query( @@ -5549,7 +5553,8 @@ private function translate_strict_grouped_order_by_query( int $group_position, int $order_position, ?int $limit_position, - int $statement_end + int $statement_end, + bool $has_distinct ): ?string { $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $group_position ); if ( null === $from_position || $projection_start === $from_position ) { @@ -5596,6 +5601,21 @@ private function translate_strict_grouped_order_by_query( return null; } + 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 null; + } + $order_sql = array(); $rewritten = false; foreach ( $order_items as $order_item ) { @@ -5736,6 +5756,160 @@ private function get_mysql_archive_grouped_projection_replacements( array $token return $replacements; } + /** + * Check whether DISTINCT is redundant for the supported weekly archive shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether this is the supported weekly archive projection. + */ + private function is_mysql_redundant_distinct_week_archive_select_shape( + array $tokens, + array $projection_items, + array $group_items, + array $archive_date_expression + ): bool { + if ( 4 !== count( $projection_items ) || 2 !== count( $group_items ) ) { + return false; + } + + $expected_aliases = array( 'week', 'yr', 'yyyymmdd', 'posts' ); + foreach ( $expected_aliases as $index => $alias ) { + if ( strtolower( $projection_items[ $index ]['alias'] ) !== $alias ) { + return false; + } + } + + return $this->is_mysql_week_expression_for_archive_date( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_year_expression_for_archive_date( + $tokens, + $projection_items[1]['expression_start'], + $projection_items[1]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_year_month_day_format_expression_for_archive_date( + $tokens, + $projection_items[2]['expression_start'], + $projection_items[2]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_count_aggregate_expression( + $tokens, + $projection_items[3]['expression_start'], + $projection_items[3]['expression_end'] + ) + && $this->do_mysql_group_items_include_week_and_year_for_archive_date( + $tokens, + $group_items, + $archive_date_expression + ); + } + + /** + * Check whether an expression is WEEK(archive_date, 1). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive week. + */ + private function is_mysql_week_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $expression = $this->get_mysql_week_argument_expression_bounds( $tokens, $start, $end ); + + return null !== $expression + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $expression['start'], + $expression['end'] + ); + } + + /** + * Check whether an expression is YEAR(archive_date). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive year. + */ + private function is_mysql_year_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $expression = $this->get_mysql_extract_argument_expression_bounds( $tokens, $start, $end, 'YEAR' ); + + return null !== $expression + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $expression['start'], + $expression['end'] + ); + } + + /** + * Check whether an expression is DATE_FORMAT(archive_date, '%Y-%m-%d'). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive date format. + */ + private function is_mysql_year_month_day_format_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $bounds = $this->get_mysql_date_format_bounds( $tokens, $start, $end ); + + return null !== $bounds + && '%Y-%m-%d' === $bounds['format'] + && $bounds['close'] + 1 === $end + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $bounds['expression_start'], + $bounds['expression_end'] + ); + } + + /** + * Check whether GROUP BY contains WEEK(archive_date, 1) and YEAR(archive_date). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether both grouped date keys are present. + */ + private function do_mysql_group_items_include_week_and_year_for_archive_date( array $tokens, array $group_items, array $archive_date_expression ): bool { + $has_week = false; + $has_year = false; + + foreach ( $group_items as $group_item ) { + $has_week = $has_week || $this->is_mysql_week_expression_for_archive_date( + $tokens, + $group_item['start'], + $group_item['end'], + $archive_date_expression + ); + $has_year = $has_year || $this->is_mysql_year_expression_for_archive_date( + $tokens, + $group_item['start'], + $group_item['end'], + $archive_date_expression + ); + } + + return $has_week && $has_year; + } + /** * Check whether a projection is exactly one COUNT aggregate. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index d5339eedf..b8e332214 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2626,6 +2626,26 @@ public function test_grouped_year_archive_order_by_uses_aggregate_sort_expressio $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. */ From 099e16e7d6014edb8b188457543f34150b1d1814 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 08:00:38 +0000 Subject: [PATCH 065/142] Support grouped HAVING aliases for PostgreSQL --- .../postgresql/class-wp-postgresql-driver.php | 641 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 62 ++ 2 files changed, 703 insertions(+) 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 index 032d94706..219d8ceb9 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -437,6 +437,12 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $translated_query = $this->translate_grouped_having_alias_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -5701,6 +5707,641 @@ private function translate_strict_grouped_order_by_query( return $sql; } + /** + * Translate grouped SELECT queries that reference projection aliases in HAVING. + * + * MySQL resolves SELECT aliases in HAVING, but PostgreSQL does not. Keep this + * rewrite limited to aliases whose projected expression is valid in a grouped + * HAVING clause so unsupported grouping shapes still fail visibly. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_grouped_having_alias_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + if ( + null !== $order_position + && ( + ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $select_end + ) + ) { + return null; + } + + $having_end = $order_position ?? $select_end; + $having_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::HAVING_SYMBOL, 1, $having_end ); + if ( null === $having_position || $having_position + 1 >= $having_end ) { + return null; + } + + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, 1, $having_position ); + if ( + null === $group_position + || ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + || $group_position + 2 >= $having_position + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $group_position ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $having_position ); + if ( null === $group_items || count( $group_items ) < 1 ) { + return null; + } + + $alias_expressions = $this->get_mysql_grouped_having_projection_alias_expressions( + $tokens, + 1, + $from_position, + $group_items + ); + if ( null === $alias_expressions || empty( $alias_expressions ) ) { + return null; + } + + $having_sql = $this->translate_mysql_having_alias_predicate_to_postgresql( + $tokens, + $having_position + 1, + $having_end, + $alias_expressions + ); + if ( null === $having_sql ) { + return null; + } + + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + if ( null !== $where_position ) { + $scope_end = $where_position; + } else { + $scope_end = $group_position; + } + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + + if ( null !== $where_position ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $group_position, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $group_position, + 'sql' => $where_sql['sql'], + ); + } + } + + $group_by_extensions = $this->get_mysql_grouped_having_group_by_projection_extensions( + $tokens, + 1, + $from_position, + $group_position, + $having_position, + $group_items + ); + if ( ! empty( $group_by_extensions ) ) { + $replacements[] = array( + 'start' => $group_position + 2, + 'end' => $having_position, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $group_position + 2, $having_position ) + . ', ' . implode( ', ', $group_by_extensions ), + ); + } + + $replacements[] = array( + 'start' => $having_position + 1, + 'end' => $having_end, + 'sql' => $having_sql, + ); + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $replacements + ); + } + + /** + * Get projection aliases that can be substituted safely in grouped HAVING. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @param array $group_items Parsed GROUP BY items. + * @return array|null Alias expressions keyed by lowercase alias. + */ + private function get_mysql_grouped_having_projection_alias_expressions( array $tokens, int $start, int $end, array $group_items ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { + return null; + } + + $aliases = array(); + foreach ( $ranges as $range ) { + $item = $this->parse_mysql_aliased_projection_expression( $tokens, $range['start'], $range['end'] ); + if ( null === $item ) { + continue; + } + + $alias_key = strtolower( $item['alias'] ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + + if ( + ! $this->contains_mysql_aggregate_call( $tokens, $item['expression_start'], $item['expression_end'] ) + && ! $this->is_mysql_grouped_projection_expression( $tokens, $item, $group_items ) + ) { + continue; + } + + $aliases[ $alias_key ] = array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $item['expression_start'], + $item['expression_end'] + ), + ); + } + + return $aliases; + } + + /** + * Get GROUP BY extensions for selected columns equivalent to grouped columns. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $having_position HAVING token position. + * @param array $group_items Parsed GROUP BY items. + * @return string[] PostgreSQL GROUP BY expressions to append. + */ + private function get_mysql_grouped_having_group_by_projection_extensions( + array $tokens, + int $projection_start, + int $from_position, + int $group_position, + int $having_position, + array $group_items + ): array { + $projection_items = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection_items ) { + return 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(); + } + + $equivalent_columns = $this->get_mysql_simple_column_equality_pairs( $tokens, $from_position + 1, $group_position ); + if ( empty( $equivalent_columns ) ) { + return array(); + } + + $extensions = array(); + $extension_keys = array(); + foreach ( $projection_items as $projection_item ) { + $bounds = $this->get_mysql_projection_expression_bounds( $tokens, $projection_item['start'], $projection_item['end'] ); + if ( null === $bounds ) { + continue; + } + + $projection_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $bounds['start'], $bounds['end'] ); + if ( null === $projection_column ) { + continue; + } + + if ( $this->is_mysql_projection_column_grouped( $projection_column, $grouped_columns ) ) { + continue; + } + + foreach ( $grouped_columns as $grouped_column ) { + if ( ! $this->are_mysql_simple_columns_equivalent( $projection_column, $grouped_column, $equivalent_columns ) ) { + continue; + } + + $extension_key = $projection_column['key']; + if ( isset( $extension_keys[ $extension_key ] ) ) { + continue 2; + } + + $extensions[] = $projection_column['sql']; + $extension_keys[ $extension_key ] = true; + continue 2; + } + } + + return $extensions; + } + + /** + * Get expression bounds for a SELECT projection item. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{start: int, end: int}|null Expression bounds, or null when malformed. + */ + private function get_mysql_projection_expression_bounds( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $expression_end = $as_position; + } elseif ( null !== $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ) ) { + $expression_end = $end - 1; + } + + return $start >= $expression_end + ? null + : array( + 'start' => $start, + 'end' => $expression_end, + ); + } + + /** + * Parse a simple qualified column expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return array{qualifier: string, column: string, key: string, sql: string}|null Column data, or null when unsupported. + */ + private function get_mysql_simple_qualified_column_expression( array $tokens, int $start, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( + $start + 3 !== $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $qualifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ); + if ( null === $qualifier || null === $column ) { + return null; + } + + $key = strtolower( $qualifier ) . '.' . strtolower( $column ); + return array( + 'qualifier' => strtolower( $qualifier ), + 'column' => strtolower( $column ), + 'key' => $key, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + ); + } + + /** + * Get simple qualified column equality pairs from JOIN/WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return array> Column equality adjacency map. + */ + private function get_mysql_simple_column_equality_pairs( array $tokens, int $start, int $end ): array { + $pairs = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + continue; + } + + if ( $position - 3 < $start || $position + 4 > $end ) { + continue; + } + + $left_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $position - 3, $position ); + $right_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $position + 1, $position + 4 ); + if ( null === $left_column || null === $right_column ) { + continue; + } + + $pairs[ $left_column['key'] ][ $right_column['key'] ] = true; + $pairs[ $right_column['key'] ][ $left_column['key'] ] = true; + } + + return $pairs; + } + + /** + * Check whether a selected column is already grouped. + * + * @param array $projection_column Selected column data. + * @param array $grouped_columns Grouped column data. + * @return bool Whether the selected column is grouped. + */ + private function is_mysql_projection_column_grouped( array $projection_column, array $grouped_columns ): bool { + foreach ( $grouped_columns as $grouped_column ) { + if ( $projection_column['key'] === $grouped_column['key'] ) { + return true; + } + } + + return false; + } + + /** + * Check whether two simple columns are connected by a parsed equality. + * + * @param array $left_column Left column data. + * @param array $right_column Right column data. + * @param array $equivalent_columns Column equality adjacency map. + * @return bool Whether the columns are equivalent. + */ + private function are_mysql_simple_columns_equivalent( array $left_column, array $right_column, array $equivalent_columns ): bool { + return $left_column['key'] === $right_column['key'] + || isset( $equivalent_columns[ $left_column['key'] ][ $right_column['key'] ] ); + } + + /** + * Parse a projection item that has an explicit or implicit alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{expression_start: int, expression_end: int, alias: string}|null Parsed alias expression, or null when absent. + */ + private function parse_mysql_aliased_projection_expression( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $alias = null; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $expression_end = $as_position; + } else { + $alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null === $alias ) { + return null; + } + + $expression_end = $end - 1; + } + + if ( $start >= $expression_end ) { + return null; + } + + return array( + 'expression_start' => $start, + 'expression_end' => $expression_end, + 'alias' => $alias, + ); + } + + /** + * Check whether a projection expression is already present in GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @param array $group_items Parsed GROUP BY items. + * @return bool Whether the projection expression is grouped. + */ + private function is_mysql_grouped_projection_expression( array $tokens, array $item, array $group_items ): bool { + foreach ( $group_items as $group_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $item['expression_start'], + $item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token range contains a MySQL aggregate function call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return bool Whether an aggregate call is present. + */ + private function contains_mysql_aggregate_call( array $tokens, int $start, int $end ): bool { + $aggregate_token_ids = array( + WP_MySQL_Lexer::AVG_SYMBOL, + WP_MySQL_Lexer::BIT_AND_SYMBOL, + WP_MySQL_Lexer::BIT_OR_SYMBOL, + WP_MySQL_Lexer::BIT_XOR_SYMBOL, + WP_MySQL_Lexer::COUNT_SYMBOL, + WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL, + WP_MySQL_Lexer::MAX_SYMBOL, + WP_MySQL_Lexer::MIN_SYMBOL, + WP_MySQL_Lexer::STD_SYMBOL, + WP_MySQL_Lexer::STDDEV_POP_SYMBOL, + WP_MySQL_Lexer::STDDEV_SAMP_SYMBOL, + WP_MySQL_Lexer::STDDEV_SYMBOL, + WP_MySQL_Lexer::SUM_SYMBOL, + WP_MySQL_Lexer::VAR_POP_SYMBOL, + WP_MySQL_Lexer::VAR_SAMP_SYMBOL, + WP_MySQL_Lexer::VARIANCE_SYMBOL, + ); + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + if ( + isset( $tokens[ $position + 1 ] ) + && in_array( $tokens[ $position ]->id, $aggregate_token_ids, true ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return true; + } + } + + return false; + } + + /** + * Translate HAVING predicate aliases to their projection expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First HAVING predicate token. + * @param int $end Final HAVING predicate token, exclusive. + * @param array $alias_expressions Projection alias SQL keyed by lowercase alias. + * @return string|null Translated HAVING SQL, or null when no alias was changed. + */ + private function translate_mysql_having_alias_predicate_to_postgresql( array $tokens, int $start, int $end, array $alias_expressions ): ?string { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + $alias = $this->get_mysql_order_by_alias_token_value( $tokens[ $position ] ?? null ); + if ( + null === $alias + || ! $this->is_unqualified_mysql_having_alias_reference( $tokens, $position, $end ) + || ! isset( $alias_expressions[ strtolower( $alias ) ] ) + ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = '(' . $alias_expressions[ strtolower( $alias ) ]['sql'] . ')'; + $segment_start = $position + 1; + $changed = true; + } + + if ( ! $changed ) { + return null; + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ); + } + + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + + /** + * Check whether a HAVING token is an unqualified alias reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate alias token position. + * @param int $end Final HAVING predicate token, exclusive. + * @return bool Whether the token can be replaced as an alias. + */ + private function is_unqualified_mysql_having_alias_reference( array $tokens, int $position, int $end ): bool { + if ( isset( $tokens[ $position - 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id ) { + return false; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && ( + WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) + ) { + return false; + } + + return $position < $end; + } + /** * Get projection replacements needed by grouped archive queries. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index b8e332214..489b99079 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2544,6 +2544,68 @@ public function test_distinct_order_by_grouped_shape_fails_closed(): void { $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 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 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. */ From 97628ff3913797dff664c51c533596b26bba1611 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 09:10:56 +0000 Subject: [PATCH 066/142] Constrain grouped HAVING equality extensions --- .../postgresql/class-wp-postgresql-driver.php | 403 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 62 +++ 2 files changed, 439 insertions(+), 26 deletions(-) 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 index 219d8ceb9..477a7b637 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -5851,6 +5851,10 @@ private function translate_grouped_having_alias_query( string $query ): ?string $having_position, $group_items ); + if ( null === $group_by_extensions ) { + return null; + } + if ( ! empty( $group_by_extensions ) ) { $replacements[] = array( 'start' => $group_position + 2, @@ -5929,7 +5933,7 @@ private function get_mysql_grouped_having_projection_alias_expressions( array $t * @param int $group_position GROUP token position. * @param int $having_position HAVING token position. * @param array $group_items Parsed GROUP BY items. - * @return string[] PostgreSQL GROUP BY expressions to append. + * @return string[]|null PostgreSQL GROUP BY expressions to append, or null when unsupported. */ private function get_mysql_grouped_having_group_by_projection_extensions( array $tokens, @@ -5938,10 +5942,10 @@ private function get_mysql_grouped_having_group_by_projection_extensions( int $group_position, int $having_position, array $group_items - ): array { + ): ?array { $projection_items = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); if ( null === $projection_items ) { - return array(); + return null; } $grouped_columns = array(); @@ -5960,13 +5964,9 @@ private function get_mysql_grouped_having_group_by_projection_extensions( return array(); } - $equivalent_columns = $this->get_mysql_simple_column_equality_pairs( $tokens, $from_position + 1, $group_position ); - if ( empty( $equivalent_columns ) ) { - return array(); - } - - $extensions = array(); - $extension_keys = array(); + $extensions = array(); + $extension_keys = array(); + $equivalent_columns = null; foreach ( $projection_items as $projection_item ) { $bounds = $this->get_mysql_projection_expression_bounds( $tokens, $projection_item['start'], $projection_item['end'] ); if ( null === $bounds ) { @@ -5982,6 +5982,18 @@ private function get_mysql_grouped_having_group_by_projection_extensions( continue; } + if ( null === $equivalent_columns ) { + $equivalent_columns = $this->get_mysql_safe_grouped_having_column_equality_pairs( + $tokens, + $from_position, + $group_position + ); + if ( null === $equivalent_columns || empty( $equivalent_columns ) ) { + return null; + } + } + + $extended = false; foreach ( $grouped_columns as $grouped_column ) { if ( ! $this->are_mysql_simple_columns_equivalent( $projection_column, $grouped_column, $equivalent_columns ) ) { continue; @@ -5989,12 +6001,18 @@ private function get_mysql_grouped_having_group_by_projection_extensions( $extension_key = $projection_column['key']; if ( isset( $extension_keys[ $extension_key ] ) ) { - continue 2; + $extended = true; + break; } $extensions[] = $projection_column['sql']; $extension_keys[ $extension_key ] = true; - continue 2; + $extended = true; + break; + } + + if ( ! $extended ) { + return null; } } @@ -6044,9 +6062,18 @@ private function get_mysql_projection_expression_bounds( array $tokens, int $sta */ private function get_mysql_simple_qualified_column_expression( array $tokens, int $start, int $end ): ?array { $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); - $start = $bounds['start']; - $end = $bounds['end']; + return $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $bounds['start'], $bounds['end'] ); + } + /** + * Parse a simple qualified column expression without removing wrapper parentheses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return array{qualifier: string, column: string, key: string, sql: string}|null Column data, or null when unsupported. + */ + private function get_mysql_unwrapped_simple_qualified_column_expression( array $tokens, int $start, int $end ): ?array { if ( $start + 3 !== $end || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) @@ -6071,38 +6098,362 @@ private function get_mysql_simple_qualified_column_expression( array $tokens, in } /** - * Get simple qualified column equality pairs from JOIN/WHERE predicates. + * Get safe qualified column equality pairs for grouped HAVING rewrites. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. - * @param int $start First token position. - * @param int $end Final token position, exclusive. - * @return array> Column equality adjacency map. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @return array>|null Column equality adjacency map, or null when unsupported. */ - private function get_mysql_simple_column_equality_pairs( array $tokens, int $start, int $end ): array { + private function get_mysql_safe_grouped_having_column_equality_pairs( array $tokens, int $from_position, int $group_position ): ?array { + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + $from_end = $where_position ?? $group_position; + + $pairs = $this->get_mysql_inner_join_column_equality_pairs( $tokens, $from_position + 1, $from_end ); + if ( null === $pairs ) { + return null === $where_position + ? $this->get_mysql_wordpress_term_split_left_join_column_equality_pairs( $tokens, $from_position + 1, $from_end ) + : null; + } + + if ( null === $where_position ) { + return $pairs; + } + + $where_pairs = $this->get_mysql_top_level_conjunct_column_equality_pairs( $tokens, $where_position + 1, $group_position ); + if ( null === $where_pairs ) { + return null; + } + + $this->merge_mysql_column_equality_pairs( $pairs, $where_pairs ); + return $pairs; + } + + /** + * Get qualified column equality pairs from supported inner JOIN predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_inner_join_column_equality_pairs( array $tokens, int $start, int $end ): ?array { $pairs = array(); for ( $position = $start; $position < $end; $position++ ) { - if ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + $token_id = $tokens[ $position ]->id; + if ( + WP_MySQL_Lexer::LEFT_SYMBOL === $token_id + || WP_MySQL_Lexer::NATURAL_SYMBOL === $token_id + || WP_MySQL_Lexer::OUTER_SYMBOL === $token_id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $token_id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token_id + || WP_MySQL_Lexer::USING_SYMBOL === $token_id + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token_id ) { + return null; + } + + if ( WP_MySQL_Lexer::ON_SYMBOL !== $token_id ) { + continue; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $end ); + $join_pairs = $this->get_mysql_top_level_conjunct_column_equality_pairs( $tokens, $position + 1, $predicate_end ); + if ( null === $join_pairs ) { + return null; + } + + $this->merge_mysql_column_equality_pairs( $pairs, $join_pairs ); + $position = $predicate_end - 1; + } + + return $pairs; + } + + /** + * Get equality pairs for WordPress core's legacy shared-term split query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_wordpress_term_split_left_join_column_equality_pairs( array $tokens, int $start, int $end ): ?array { + $term_taxonomy_reference = $this->parse_mysql_table_reference( $tokens, $start, $end ); + if ( + null === $term_taxonomy_reference + || ! $this->is_mysql_wordpress_table_reference( $term_taxonomy_reference, 'term_taxonomy', 'tt' ) + ) { + return null; + } + + $position = $term_taxonomy_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $terms_reference = $this->parse_mysql_table_reference( $tokens, $position + 1, $end ); + if ( + null === $terms_reference + || ! $this->is_mysql_wordpress_table_reference( $terms_reference, 'terms', 't' ) + ) { + return null; + } + + $position = $terms_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $end ); + if ( $predicate_end !== $end ) { + return null; + } + + $pair = $this->get_mysql_top_level_simple_column_equality_pair( $tokens, $position + 1, $predicate_end ); + if ( null === $pair || ! $this->is_mysql_wordpress_term_split_column_equality_pair( $pair ) ) { + return null; + } + + return array( + 't.term_id' => array( + 'tt.term_id' => true, + ), + 'tt.term_id' => array( + 't.term_id' => true, + ), + ); + } + + /** + * Check whether a table reference matches a WordPress core table and alias. + * + * @param array $reference Parsed table reference. + * @param string $table_base Expected unprefixed table name. + * @param string $alias Expected alias. + * @return bool Whether the reference matches. + */ + private function is_mysql_wordpress_table_reference( array $reference, string $table_base, string $alias ): bool { + $reference_alias = strtolower( null === $reference['alias'] ? $reference['table'] : $reference['alias'] ); + if ( $alias !== $reference_alias ) { + return false; + } + + $table_name = strtolower( $reference['table'] ); + return $table_base === $table_name + || substr( $table_name, -strlen( '_' . $table_base ) ) === '_' . $table_base; + } + + /** + * Check whether an equality pair is t.term_id = tt.term_id. + * + * @param array $pair Parsed equality pair. + * @return bool Whether this is the WordPress shared-term split equality. + */ + private function is_mysql_wordpress_term_split_column_equality_pair( array $pair ): bool { + return ( + 't.term_id' === $pair['left']['key'] + && 'tt.term_id' === $pair['right']['key'] + ) || ( + 'tt.term_id' === $pair['left']['key'] + && 't.term_id' === $pair['right']['key'] + ); + } + + /** + * Find the end of a JOIN ON predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ON predicate token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return int Final ON predicate token position, exclusive. + */ + private function find_mysql_join_predicate_end( array $tokens, int $start, int $end ): int { + $depth = 0; + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; continue; } - if ( $position - 3 < $start || $position + 4 > $end ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; continue; } - $left_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $position - 3, $position ); - $right_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $position + 1, $position + 4 ); - if ( null === $left_column || null === $right_column ) { + if ( + 0 === $depth + && ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::LEFT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::NATURAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $tokens[ $position ]->id + ) + ) { + return $position; + } + } + + return $end; + } + + /** + * Get column equality pairs from a top-level AND predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_top_level_conjunct_column_equality_pairs( array $tokens, int $start, int $end ): ?array { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return null; + } + + $pairs = array(); + foreach ( $conjuncts as $conjunct ) { + $pair = $this->get_mysql_top_level_simple_column_equality_pair( + $tokens, + $conjunct['start'], + $conjunct['end'] + ); + if ( null === $pair ) { continue; } - $pairs[ $left_column['key'] ][ $right_column['key'] ] = true; - $pairs[ $right_column['key'] ][ $left_column['key'] ] = true; + $pairs[ $pair['left']['key'] ][ $pair['right']['key'] ] = true; + $pairs[ $pair['right']['key'] ][ $pair['left']['key'] ] = true; } return $pairs; } + /** + * Split a boolean predicate into top-level AND conjuncts. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array|null Conjunct bounds, or null when unsupported. + */ + private function split_mysql_top_level_boolean_conjuncts( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $conjuncts = array(); + $conjunct_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id || WP_MySQL_Lexer::XOR_SYMBOL === $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( $conjunct_start === $position ) { + return null; + } + + $conjuncts[] = array( + 'start' => $conjunct_start, + 'end' => $position, + ); + $conjunct_start = $position + 1; + } + + if ( 0 !== $depth || $conjunct_start >= $end ) { + return null; + } + + $conjuncts[] = array( + 'start' => $conjunct_start, + 'end' => $end, + ); + + return $conjuncts; + } + + /** + * Parse a top-level simple qualified-column equality predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array{left: array, right: array}|null Equality pair, or null when unsupported. + */ + private function get_mysql_top_level_simple_column_equality_pair( array $tokens, int $start, int $end ): ?array { + $equal_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $start, $end ); + if ( + null === $equal_position + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $equal_position + 1, $end ) + ) { + return null; + } + + $left_column = $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $start, $equal_position ); + $right_column = $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $equal_position + 1, $end ); + if ( null === $left_column || null === $right_column ) { + return null; + } + + return array( + 'left' => $left_column, + 'right' => $right_column, + ); + } + + /** + * Merge column equality adjacency maps. + * + * @param array $target Target adjacency map. + * @param array $source Source adjacency map. + */ + private function merge_mysql_column_equality_pairs( array &$target, array $source ): void { + foreach ( $source as $left_key => $right_columns ) { + foreach ( $right_columns as $right_key => $_ ) { + $target[ $left_key ][ $right_key ] = true; + } + } + } + /** * Check whether a selected column is already grouped. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 489b99079..606bc31f8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2576,6 +2576,23 @@ public function test_grouped_having_aggregate_alias_is_translated_for_postgresql $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. */ @@ -2591,6 +2608,51 @@ public function test_grouped_having_non_alias_identifier_fails_closed(): void { $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. */ From b8a4a7fd6046ed1e2ba2947831a1a950ae3ffea1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 12:02:04 +0000 Subject: [PATCH 067/142] Translate PostgreSQL duplicate-key inserts --- .../postgresql/class-wp-postgresql-driver.php | 169 +++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 117 +++++++++--- 2 files changed, 235 insertions(+), 51 deletions(-) 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 index 477a7b637..3050409f3 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -387,7 +387,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } - $translated_query = $this->translate_wordpress_options_upsert_query( $query ); + $translated_query = $this->translate_mysql_on_duplicate_key_update_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; $translated_for_postgresql = true; @@ -3166,17 +3166,17 @@ private function translate_simple_mysql_delete_query( string $query ): ?string { } /** - * Translate WordPress options INSERT ... ON DUPLICATE KEY UPDATE queries. + * Translate supported INSERT ... ON DUPLICATE KEY UPDATE queries. * - * WordPress installation upserts rows into the options table through MySQL's - * ON DUPLICATE KEY syntax. Keep this intentionally narrow: prefixed options - * tables conflict on option_name, and update assignments must be - * "column = VALUES(column)" so unsupported INSERT shapes still reach PDO. + * WordPress emits MySQL upserts for a small set of VALUES inserts. Keep this + * path structured and metadata-backed: only explicit column-list VALUES + * inserts are supported, the conflict target must resolve to a known + * primary/unique key, and update assignments must use VALUES(column). * * @param string $query MySQL query. * @return string|null PostgreSQL query, or null when the query is unsupported. */ - private function translate_wordpress_options_upsert_query( string $query ): ?string { + private function translate_mysql_on_duplicate_key_update_query( string $query ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { return null; @@ -3189,7 +3189,7 @@ private function translate_wordpress_options_upsert_query( string $query ): ?str ++$position; $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); - if ( null === $table_name || ! $this->is_wordpress_options_table_name( $table_name ) ) { + if ( null === $table_name ) { return null; } @@ -3199,29 +3199,35 @@ private function translate_wordpress_options_upsert_query( string $query ): ?str return null; } - $column_lookup = array(); - foreach ( $columns as $column ) { - $column_lookup[ strtolower( $column ) ] = true; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; } - if ( ! isset( $column_lookup['option_name'] ) ) { + ++$position; + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position ); + if ( null === $on_duplicate ) { return null; } - if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + $values = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ) ); + if ( null === $values ) { return null; } - $values_start = $position; - $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position + 1 ); - if ( null === $on_duplicate ) { + $conflict_columns = $this->get_mysql_upsert_conflict_target_columns( $table_name, $columns ); + if ( null === $conflict_columns ) { return null; } - $values_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $values_start, $on_duplicate ); - $position = $on_duplicate + 4; + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = true; + } - $assignments = $this->parse_upsert_update_assignments( $tokens, $position, $column_lookup ); + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $position = $on_duplicate + 4; + + $assignments = $this->parse_upsert_update_assignments( $tokens, $position, $column_lookup, $table_column_lookup ); if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { return null; } @@ -3230,12 +3236,118 @@ private function translate_wordpress_options_upsert_query( string $query ): ?str 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', $this->connection->quote_identifier( $table_name ), implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), - $values_sql, - $this->connection->quote_identifier( 'option_name' ), + 'VALUES ' . implode( ', ', $values ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $conflict_columns ) ), implode( ', ', $assignments ) ); } + /** + * Parse a bounded sequence of one or more MySQL VALUES rows. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final token position, exclusive. + * @param int $expected_count Expected number of row values. + * @return string[]|null PostgreSQL VALUES row SQL fragments, or null when unsupported. + */ + private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count ): ?array { + $rows = array(); + + while ( $position < $end ) { + $values = $this->parse_mysql_value_list( $tokens, $position ); + if ( null === $values || count( $values ) !== $expected_count ) { + return null; + } + + $rows[] = '(' . implode( ', ', $values ) . ')'; + + if ( $position === $end ) { + return $rows; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + } + + return count( $rows ) > 0 ? $rows : null; + } + + /** + * Resolve the PostgreSQL upsert conflict target from MySQL index metadata. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return string[]|null Conflict target columns, or null when unsupported. + */ + private function get_mysql_upsert_conflict_target_columns( string $table_name, array $columns ): ?array { + $insert_column_lookup = array(); + foreach ( $columns as $column ) { + $insert_column_lookup[ strtolower( $column ) ] = true; + } + + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? AND non_unique = \'0\' + ORDER BY + CASE WHEN UPPER(key_name) = \'PRIMARY\' THEN 0 ELSE 1 END, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'columns' => array(), + 'has_sub_part' => false, + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['columns'][] = $column_name; + if ( null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ) { + $indexes[ $key_name ]['has_sub_part'] = true; + } + } + + foreach ( $indexes as $index ) { + if ( empty( $index['columns'] ) || $index['has_sub_part'] ) { + continue; + } + + foreach ( $index['columns'] as $column ) { + if ( ! isset( $insert_column_lookup[ strtolower( $column ) ] ) ) { + continue 2; + } + } + + return $index['columns']; + } + + return null; + } + /** * Translate simple single-row MySQL REPLACE statements to PostgreSQL. * @@ -7932,17 +8044,21 @@ private function find_on_duplicate_key_update_clause( array $tokens, int $positi /** * Parse ON DUPLICATE KEY UPDATE assignments for the supported upsert shape. * - * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. - * @param int $position Current token position, updated on success. - * @param array $column_lookup Insert-column lookup by lowercase name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $column_lookup Insert-column lookup by lowercase name. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. * @return string[]|null PostgreSQL SET assignments, or null when unsupported. */ - private function parse_upsert_update_assignments( array $tokens, int &$position, array $column_lookup ): ?array { + private function parse_upsert_update_assignments( array $tokens, int &$position, array $column_lookup, array $table_column_lookup ): ?array { $assignments = array(); while ( isset( $tokens[ $position ] ) ) { $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); - if ( null === $target_column ) { + if ( + null === $target_column + || ! isset( $table_column_lookup[ strtolower( $target_column ) ] ) + ) { return null; } @@ -7964,7 +8080,6 @@ private function parse_upsert_update_assignments( array $tokens, int &$position, $source_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 2 ] ); if ( null === $source_column - || strtolower( $source_column ) !== strtolower( $target_column ) || ! isset( $column_lookup[ strtolower( $source_column ) ] ) ) { return null; diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 606bc31f8..472ac1dd5 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -941,16 +941,9 @@ public function test_transaction_alias_and_commit_delegate_to_pdo(): void { public function test_wordpress_options_upsert_is_translated_to_postgresql_on_conflict(): void { $driver = $this->create_driver(); - $driver->query( - 'CREATE TABLE wp_options ( - option_id INTEGER PRIMARY KEY AUTOINCREMENT, - option_name TEXT NOT NULL UNIQUE, - option_value TEXT NOT NULL, - autoload TEXT NOT NULL - )' - ); + $this->install_options_table_with_mysql_metadata( $driver ); - $insert = "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) + $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`), @@ -961,14 +954,14 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $this->assertSame( array( array( - 'sql' => 'INSERT INTO "wp_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"', + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', 'params' => array(), ), ), $driver->get_last_postgresql_queries() ); - $update = "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) + $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`), @@ -978,20 +971,74 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $this->assertSame( array( array( - 'sql' => 'INSERT INTO "wp_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"', + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', 'params' => array(), ), ), $driver->get_last_postgresql_queries() ); - $rows = $driver->query( "SELECT option_value, autoload FROM wp_options WHERE option_name = 'siteurl'" ); + $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 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 simple WordPress UPDATE statements are translated to PostgreSQL. */ @@ -3299,21 +3346,23 @@ public function test_mysql_date_add_with_unsupported_interval_unit_fails_closed( public function test_unsupported_options_upsert_still_reaches_backend(): 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 - )' + $this->install_options_table_with_mysql_metadata( $driver ); + + $unsupported_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net'"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $unsupported_upsert + ) ); $this->expectException( PDOException::class ); - $driver->query( - "INSERT INTO `wp_options` (`option_name`, `option_value`, `autoload`) - VALUES ('siteurl', 'http://example.org', 'yes') - ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net'" - ); + $driver->query( $unsupported_upsert ); } /** @@ -4090,6 +4139,26 @@ private function install_options_table_with_mysql_metadata( WP_PostgreSQL_Driver ); } + /** + * 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 + ) + ); + } + /** * Creates a PostgreSQL driver backed by an injected in-memory PDO. * From 256a6dcfff45c269f551a5fbb15c4ebd92b81875 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 12:19:04 +0000 Subject: [PATCH 068/142] Guard PostgreSQL duplicate-key upserts --- .../postgresql/class-wp-postgresql-driver.php | 199 +++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 127 +++++++++++ 2 files changed, 302 insertions(+), 24 deletions(-) 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 index 3050409f3..6e860906f 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -387,9 +387,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } - $translated_query = $this->translate_mysql_on_duplicate_key_update_query( $query ); - if ( null !== $translated_query ) { - $query = $translated_query; + $upsert_query = $this->translate_mysql_on_duplicate_key_update_query( $query ); + if ( null !== $upsert_query ) { + $query = $upsert_query['sql']; + $dml_identity_repair_query = $upsert_query; $translated_for_postgresql = true; } @@ -3174,9 +3175,9 @@ private function translate_simple_mysql_delete_query( string $query ): ?string { * primary/unique key, and update assignments must use VALUES(column). * * @param string $query MySQL query. - * @return string|null PostgreSQL query, or null when the query is unsupported. + * @return array|null PostgreSQL query data, or null when the query is unsupported. */ - private function translate_mysql_on_duplicate_key_update_query( string $query ): ?string { + private function translate_mysql_on_duplicate_key_update_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { return null; @@ -3209,8 +3210,8 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): return null; } - $values = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ) ); - if ( null === $values ) { + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ) ); + if ( null === $value_rows ) { return null; } @@ -3232,13 +3233,37 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): return null; } - return sprintf( - 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', - $this->connection->quote_identifier( $table_name ), - implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), - 'VALUES ' . implode( ', ', $values ), - implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $conflict_columns ) ), - implode( ', ', $assignments ) + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $value_rows, + $conflict_columns + ); + if ( null === $inserted_value_rows ) { + return null; + } + + $sql_value_rows = array(); + foreach ( $value_rows as $values ) { + $sql_value_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + return array( + 'action' => 'upsert', + 'sql' => sprintf( + 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + 'VALUES ' . implode( ', ', $sql_value_rows ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $conflict_columns ) ), + implode( ', ', $assignments ) + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $inserted_value_rows[0] ?? array(), + 'value_rows' => $inserted_value_rows, + 'conflict_columns' => $conflict_columns, + 'inserted_new_row' => count( $inserted_value_rows ) > 0, ); } @@ -3249,7 +3274,7 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): * @param int $position Current token position, updated on success. * @param int $end Final token position, exclusive. * @param int $expected_count Expected number of row values. - * @return string[]|null PostgreSQL VALUES row SQL fragments, or null when unsupported. + * @return array[]|null Translated PostgreSQL VALUES rows, or null when unsupported. */ private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count ): ?array { $rows = array(); @@ -3260,7 +3285,7 @@ private function parse_mysql_values_rows( array $tokens, int &$position, int $en return null; } - $rows[] = '(' . implode( ', ', $values ) . ')'; + $rows[] = $values; if ( $position === $end ) { return $rows; @@ -3331,6 +3356,7 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a } } + $candidates = array(); foreach ( $indexes as $index ) { if ( empty( $index['columns'] ) || $index['has_sub_part'] ) { continue; @@ -3342,10 +3368,103 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a } } - return $index['columns']; + $candidates[] = $index['columns']; } - return null; + return 1 === count( $candidates ) ? $candidates[0] : null; + } + + /** + * Get the VALUES rows that will insert rather than update on conflict. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param string[] $conflict_columns Conflict target columns. + * @return array[]|null Inserted VALUES rows, or null when unsupported. + */ + private function get_mysql_upsert_inserted_value_rows( string $table_name, array $columns, array $value_rows, array $conflict_columns ): ?array { + $column_indexes = array(); + foreach ( $columns as $index => $column ) { + $column_indexes[ strtolower( $column ) ] = $index; + } + + $conflict_indexes = array(); + foreach ( $conflict_columns as $column ) { + $column_key = strtolower( $column ); + if ( ! isset( $column_indexes[ $column_key ] ) ) { + return null; + } + + $conflict_indexes[] = array( + 'column' => $column, + 'index' => $column_indexes[ $column_key ], + ); + } + + $inserted_rows = array(); + foreach ( $value_rows as $values ) { + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + if ( $conflict_exists ) { + continue; + } + + $inserted_rows[] = $values; + } + + return $inserted_rows; + } + + /** + * Check whether a VALUES row conflicts with the selected upsert target. + * + * @param string $table_name Table name. + * @param array $values Translated PostgreSQL VALUES row. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return bool|null Whether the row currently conflicts, or null when unsupported. + */ + private function mysql_upsert_conflict_exists( string $table_name, array $values, array $conflict_indexes ): ?bool { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $value = (string) ( $values[ $conflict_index['index'] ] ?? '' ); + if ( ! $this->is_mysql_upsert_conflict_probe_value_supported( $value ) ) { + return null; + } + + if ( 'NULL' === strtoupper( trim( $value ) ) ) { + return false; + } + + $where[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( (string) $conflict_index['column'] ), + $value + ); + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE %s LIMIT 1', + $this->connection->quote_identifier( $table_name ), + implode( ' AND ', $where ) + ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether a conflict probe can safely evaluate a translated value. + * + * @param string $value_sql Translated PostgreSQL value SQL. + * @return bool Whether the value is supported. + */ + private function is_mysql_upsert_conflict_probe_value_supported( string $value_sql ): bool { + return '' !== trim( $value_sql ) && 'DEFAULT' !== strtoupper( trim( $value_sql ) ); } /** @@ -3603,17 +3722,26 @@ private function repair_dml_identity_sequences_after_success( array $dml_query, } if ( - ! isset( $dml_query['table_name'], $dml_query['columns'], $dml_query['values'] ) + ! isset( $dml_query['table_name'], $dml_query['columns'] ) || ! is_array( $dml_query['columns'] ) - || ! is_array( $dml_query['values'] ) ) { return; } - $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup( - $dml_query['columns'], - $dml_query['values'] - ); + if ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup_from_rows( + $dml_query['columns'], + $dml_query['value_rows'] + ); + } elseif ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup( + $dml_query['columns'], + $dml_query['values'] + ); + } else { + return; + } + if ( empty( $explicit_identity_columns ) || ! $this->is_postgresql_catalog_available_for_dml_identity_repair() ) { return; } @@ -3669,6 +3797,29 @@ private function get_explicit_dml_identity_column_lookup( array $columns, array return $explicit_columns; } + /** + * Get explicitly supplied non-default DML identity columns from VALUES rows. + * + * @param string[] $columns DML column names. + * @param array[] $value_rows Translated DML value rows. + * @return array Lowercase column lookup. + */ + private function get_explicit_dml_identity_column_lookup_from_rows( array $columns, array $value_rows ): array { + $explicit_columns = array(); + + foreach ( $value_rows as $values ) { + if ( ! is_array( $values ) ) { + continue; + } + + foreach ( $this->get_explicit_dml_identity_column_lookup( $columns, $values ) as $column => $explicit ) { + $explicit_columns[ $column ] = $explicit; + } + } + + return $explicit_columns; + } + /** * Check whether a DML value is an explicit identity value. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 472ac1dd5..df16d87a2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -278,6 +278,63 @@ public function test_failed_identity_insert_does_not_repair_sequence(): void { } } + /** + * Tests explicit identity upsert insert paths repair PostgreSQL sequences. + */ + public function test_explicit_identity_upsert_insert_repairs_sequence_after_success(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'identity') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (7, \'identity\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests explicit identity upsert conflict paths do not repair sequences. + */ + public function test_explicit_identity_upsert_conflict_update_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (7, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (7, \'updated\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT value FROM wptests_identity_upsert WHERE id = 7' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'updated', $rows[0]->value ); + } + /** * Tests simple WordPress REPLACE statements update through PostgreSQL upserts. */ @@ -1039,6 +1096,31 @@ public function test_multi_row_on_duplicate_key_update_uses_metadata_conflict_ta $this->assertSame( '3', $rows[2]->term_order ); } + /** + * Tests ambiguous duplicate-key arbiters fail closed. + */ + public function test_ambiguous_on_duplicate_key_update_returns_null(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) VALUES (2, 'existing', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ) + ); + + $this->expectException( PDOException::class ); + + $driver->query( $upsert ); + } + /** * Tests simple WordPress UPDATE statements are translated to PostgreSQL. */ @@ -4159,6 +4241,51 @@ private function install_term_relationships_table_with_mysql_metadata( WP_Postgr ); } + /** + * Install an upsert table with ambiguous MySQL duplicate-key metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE ambiguous_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + } + + /** + * Install an identity table with MySQL auto_increment metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_identity_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_identity_upsert ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_upsert ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + } + /** * Creates a PostgreSQL driver backed by an injected in-memory PDO. * From 066127191f26dd6ee9e049450c91b035df8a92b3 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 12:32:35 +0000 Subject: [PATCH 069/142] Fail closed unsafe PostgreSQL upserts --- .../postgresql/class-wp-postgresql-driver.php | 140 +++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 75 ++++++++++ 2 files changed, 195 insertions(+), 20 deletions(-) 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 index 6e860906f..d046ab4f6 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3210,7 +3210,8 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): return null; } - $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ) ); + $probe_safe_rows = array(); + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ), $probe_safe_rows ); if ( null === $value_rows ) { return null; } @@ -3237,6 +3238,7 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): $table_name, $columns, $value_rows, + $probe_safe_rows, $conflict_columns ); if ( null === $inserted_value_rows ) { @@ -3274,18 +3276,22 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): * @param int $position Current token position, updated on success. * @param int $end Final token position, exclusive. * @param int $expected_count Expected number of row values. + * @param array $probe_safe_rows Updated with conflict-probe safety flags. * @return array[]|null Translated PostgreSQL VALUES rows, or null when unsupported. */ - private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count ): ?array { - $rows = array(); + private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count, array &$probe_safe_rows ): ?array { + $rows = array(); + $probe_safe_rows = array(); while ( $position < $end ) { - $values = $this->parse_mysql_value_list( $tokens, $position ); + $probe_safe_values = array(); + $values = $this->parse_mysql_value_list_with_probe_safety( $tokens, $position, $probe_safe_values ); if ( null === $values || count( $values ) !== $expected_count ) { return null; } - $rows[] = $values; + $rows[] = $values; + $probe_safe_rows[] = $probe_safe_values; if ( $position === $end ) { return $rows; @@ -3301,6 +3307,98 @@ private function parse_mysql_values_rows( array $tokens, int &$position, int $en return count( $rows ) > 0 ? $rows : null; } + /** + * Parse a parenthesized single-row MySQL VALUES list with probe safety. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param bool[] $probe_safety Updated with per-value conflict-probe safety. + * @return string[]|null Translated SQL values, or null when unsupported. + */ + private function parse_mysql_value_list_with_probe_safety( array $tokens, int &$position, array &$probe_safety ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = array(); + $probe_safety = array(); + $value_start = $position; + $depth = 0; + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( 0 === $depth ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + ++$position; + return $values; + } + + --$depth; + ++$position; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + $value_start = $position + 1; + } + + ++$position; + } + + return null; + } + + /** + * Check whether a VALUES item is safe for a conflict preflight probe. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position, inclusive. + * @param int $end Final value token position, exclusive. + * @return bool Whether the value is a deterministic literal. + */ + private function is_supported_mysql_upsert_conflict_probe_token_sequence( array $tokens, int $start, int $end ): bool { + if ( $start + 1 !== $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + return in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::BIN_NUMBER, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::FALSE_SYMBOL, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + /** * Resolve the PostgreSQL upsert conflict target from MySQL index metadata. * @@ -3358,7 +3456,7 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a $candidates = array(); foreach ( $indexes as $index ) { - if ( empty( $index['columns'] ) || $index['has_sub_part'] ) { + if ( empty( $index['columns'] ) ) { continue; } @@ -3368,6 +3466,10 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a } } + if ( $index['has_sub_part'] ) { + return null; + } + $candidates[] = $index['columns']; } @@ -3380,10 +3482,11 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a * @param string $table_name Table name. * @param string[] $columns Inserted column names. * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. * @param string[] $conflict_columns Conflict target columns. * @return array[]|null Inserted VALUES rows, or null when unsupported. */ - private function get_mysql_upsert_inserted_value_rows( string $table_name, array $columns, array $value_rows, array $conflict_columns ): ?array { + private function get_mysql_upsert_inserted_value_rows( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_columns ): ?array { $column_indexes = array(); foreach ( $columns as $index => $column ) { $column_indexes[ strtolower( $column ) ] = $index; @@ -3403,7 +3506,14 @@ private function get_mysql_upsert_inserted_value_rows( string $table_name, array } $inserted_rows = array(); - foreach ( $value_rows as $values ) { + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return null; + } + } + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); if ( null === $conflict_exists ) { return null; @@ -3430,11 +3540,11 @@ private function get_mysql_upsert_inserted_value_rows( string $table_name, array private function mysql_upsert_conflict_exists( string $table_name, array $values, array $conflict_indexes ): ?bool { $where = array(); foreach ( $conflict_indexes as $conflict_index ) { - $value = (string) ( $values[ $conflict_index['index'] ] ?? '' ); - if ( ! $this->is_mysql_upsert_conflict_probe_value_supported( $value ) ) { + if ( ! array_key_exists( $conflict_index['index'], $values ) ) { return null; } + $value = (string) $values[ $conflict_index['index'] ]; if ( 'NULL' === strtoupper( trim( $value ) ) ) { return false; } @@ -3457,16 +3567,6 @@ private function mysql_upsert_conflict_exists( string $table_name, array $values return false !== $stmt->fetchColumn(); } - /** - * Check whether a conflict probe can safely evaluate a translated value. - * - * @param string $value_sql Translated PostgreSQL value SQL. - * @return bool Whether the value is supported. - */ - private function is_mysql_upsert_conflict_probe_value_supported( string $value_sql ): bool { - return '' !== trim( $value_sql ) && 'DEFAULT' !== strtoupper( trim( $value_sql ) ); - } - /** * Translate simple single-row MySQL REPLACE statements to PostgreSQL. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index df16d87a2..a9a183038 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -335,6 +335,37 @@ public function test_explicit_identity_upsert_conflict_update_does_not_repair_se $this->assertSame( 'updated', $rows[0]->value ); } + /** + * Tests probe-unsafe identity upsert expressions fail closed. + */ + public function test_probe_unsafe_identity_upsert_expression_returns_null(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (2, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (next_identity_value(), 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ) + ); + + try { + $driver->query( $upsert ); + $this->fail( 'Probe-unsafe upsert expression should fail closed before sequence repair.' ); + } catch ( PDOException $e ) { + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + } + /** * Tests simple WordPress REPLACE statements update through PostgreSQL upserts. */ @@ -1121,6 +1152,26 @@ public function test_ambiguous_on_duplicate_key_update_returns_null(): void { $driver->query( $upsert ); } + /** + * Tests prefix unique duplicate-key arbiters fail closed. + */ + public function test_prefix_unique_on_duplicate_key_update_returns_null(): void { + $driver = $this->create_driver(); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `prefix_ambiguous` (`id`, `slug`, `value`) VALUES (2, 'existing-slug', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ) + ); + } + /** * Tests simple WordPress UPDATE statements are translated to PostgreSQL. */ @@ -4265,6 +4316,30 @@ private function install_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreS ); } + /** + * Install an upsert table with a MySQL prefix unique key. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_prefix_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE prefix_ambiguous ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE prefix_ambiguous ( + id bigint(20) unsigned NOT NULL, + slug varchar(255) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug(10)) + )' + ); + } + /** * Install an identity table with MySQL auto_increment metadata. * From 83509ab88ce2a89bf0a66b5d546a6e536a36a9af Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 15:43:11 +0000 Subject: [PATCH 070/142] Implement PostgreSQL FOUND_ROWS accounting --- .../postgresql/class-wp-postgresql-driver.php | 147 +++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 61 ++++++-- 2 files changed, 173 insertions(+), 35 deletions(-) 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 index d046ab4f6..b9474b6a9 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -69,7 +69,7 @@ class WP_PostgreSQL_Driver { private $last_postgresql_queries = array(); /** - * Approximate FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. + * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. * * @var int */ @@ -425,6 +425,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); + $sql_calc_found_rows_query = $is_sql_calc_found_rows_query ? $query : null; $translated_query = $this->translate_information_schema_tables_site_health_query( $query ); if ( null !== $translated_query ) { @@ -480,8 +481,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $stmt->columnCount() > 0 ) { $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); - if ( $is_sql_calc_found_rows_query ) { - $this->last_found_rows = count( $this->last_result ); + if ( 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->last_column_meta = array(); @@ -498,6 +499,72 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + /** + * Execute the unbounded count query for a SQL_CALC_FOUND_ROWS SELECT. + * + * @param string $query MySQL query. + * @return int Total matching rows before LIMIT/OFFSET. + */ + private function execute_sql_calc_found_rows_count_query( string $query ): int { + $count_query = $this->get_sql_calc_found_rows_count_query( $query ); + if ( null === $count_query ) { + throw new PDOException( 'Unsupported SQL_CALC_FOUND_ROWS query shape for PostgreSQL FOUND_ROWS accounting.' ); + } + + $stmt = $this->connection->query( $count_query ); + $this->last_postgresql_queries[] = array( + 'sql' => $count_query, + 'params' => array(), + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || ! array_key_exists( '__wp_pg_found_rows', $row ) ) { + throw new PDOException( 'Failed to read PostgreSQL FOUND_ROWS accounting result.' ); + } + + return (int) $row['__wp_pg_found_rows']; + } + + /** + * Build the PostgreSQL count query for a SQL_CALC_FOUND_ROWS SELECT. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL count query, or null when unsupported. + */ + private function get_sql_calc_found_rows_count_query( string $query ): ?string { + $select_query = $this->translate_sql_calc_found_rows_count_select_query( $query ); + if ( null === $select_query ) { + return null; + } + + $alias = $this->connection->quote_identifier( '__wp_pg_found_rows' ); + return sprintf( + 'SELECT COUNT(*) AS %1$s FROM (%2$s) AS %1$s', + $alias, + $select_query + ); + } + + /** + * Translate the unbounded SELECT used for SQL_CALC_FOUND_ROWS accounting. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL SELECT query, or null when unsupported. + */ + private function translate_sql_calc_found_rows_count_select_query( string $query ): ?string { + $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query, false ); + if ( null !== $translated_query ) { + return $translated_query; + } + + $translated_query = $this->translate_distinct_order_by_query( $query, false ); + if ( null !== $translated_query ) { + return $translated_query; + } + + return $this->translate_sql_calc_found_rows_select_query( $query, false ); + } + /** * Execute translated PostgreSQL statements for a single MySQL-facing query. * @@ -5084,10 +5151,11 @@ private function get_information_schema_tables_site_health_table_rows_sql( array * by a hidden aggregate keeps the MySQL-visible result shape and avoids * changing DISTINCT cardinality. * - * @param string $query MySQL query. + * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string|null PostgreSQL query, or null when the query is unsupported. */ - private function translate_distinct_order_by_query( string $query ): ?string { + private function translate_distinct_order_by_query( string $query, bool $include_limit = true ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { return null; @@ -5133,9 +5201,16 @@ private function translate_distinct_order_by_query( string $query ): ?string { $select_end ); if ( null === $order_position ) { - return $has_sql_calc_found_rows - ? 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $statement_end ) - : null; + if ( ! $has_sql_calc_found_rows ) { + return null; + } + + $sql = 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select_end ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; } $from_position = $this->find_top_level_mysql_token( @@ -5228,9 +5303,16 @@ private function translate_distinct_order_by_query( string $query ): ?string { } if ( ! $has_hidden_order_expression ) { - return $has_sql_calc_found_rows - ? 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $statement_end ) - : null; + if ( ! $has_sql_calc_found_rows ) { + return null; + } + + $sql = 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select_end ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; } return $this->build_distinct_order_by_grouped_query( @@ -5240,7 +5322,8 @@ private function translate_distinct_order_by_query( string $query ): ?string { $from_position, $order_position, $limit_position, - $statement_end + $statement_end, + $include_limit ); } @@ -5674,6 +5757,7 @@ private function contains_mysql_token( array $tokens, int $start, int $end, arra * @param int $order_position ORDER token position. * @param int|null $limit_position LIMIT token position, or null. * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string PostgreSQL query. */ private function build_distinct_order_by_grouped_query( @@ -5683,7 +5767,8 @@ private function build_distinct_order_by_grouped_query( int $from_position, int $order_position, ?int $limit_position, - int $statement_end + int $statement_end, + bool $include_limit = true ): string { $derived_table_alias = '__wp_pg_distinct'; $quoted_derived_table_alias = $this->connection->quote_identifier( $derived_table_alias ); @@ -5728,7 +5813,7 @@ private function build_distinct_order_by_grouped_query( $this->get_distinct_order_by_outer_order_sql( $projection_items, $order_items, $quoted_derived_table_alias ) ); - if ( null !== $limit_position ) { + if ( $include_limit && null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } @@ -5781,10 +5866,11 @@ private function get_distinct_order_by_outer_order_sql( array $projection_items, * this rewrite limited to WordPress's scalar count and grouped archive/comment * ID query shapes so unsupported grouping semantics still fail visibly. * - * @param string $query MySQL query. + * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string|null PostgreSQL query, or null when the query is unsupported. */ - private function translate_strict_aggregate_grouped_order_by_query( string $query ): ?string { + private function translate_strict_aggregate_grouped_order_by_query( string $query, bool $include_limit = true ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { return null; @@ -5847,7 +5933,8 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer $projection_start, $order_position, $limit_position, - $statement_end + $statement_end, + $include_limit ); } @@ -5866,7 +5953,8 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer $order_position, $limit_position, $statement_end, - $has_distinct + $has_distinct, + $include_limit ); } @@ -5878,6 +5966,7 @@ private function translate_strict_aggregate_grouped_order_by_query( string $quer * @param int $order_position ORDER token position. * @param int|null $limit_position LIMIT token position, or null. * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string|null PostgreSQL query, or null when unsupported. */ private function translate_strict_aggregate_only_order_by_query( @@ -5885,7 +5974,8 @@ private function translate_strict_aggregate_only_order_by_query( int $projection_start, int $order_position, ?int $limit_position, - int $statement_end + 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 ) { @@ -5897,7 +5987,7 @@ private function translate_strict_aggregate_only_order_by_query( } $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $order_position ); - if ( null !== $limit_position ) { + if ( $include_limit && null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } @@ -5914,6 +6004,7 @@ private function translate_strict_aggregate_only_order_by_query( * @param int|null $limit_position LIMIT token position, or null. * @param int $statement_end Final statement token position, exclusive. * @param bool $has_distinct Whether the original SELECT used DISTINCT. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string|null PostgreSQL query, or null when unsupported. */ private function translate_strict_grouped_order_by_query( @@ -5923,7 +6014,8 @@ private function translate_strict_grouped_order_by_query( int $order_position, ?int $limit_position, int $statement_end, - bool $has_distinct + bool $has_distinct, + bool $include_limit = true ): ?string { $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $group_position ); if ( null === $from_position || $projection_start === $from_position ) { @@ -6063,7 +6155,7 @@ private function translate_strict_grouped_order_by_query( $replacements ) . ' ORDER BY ' . implode( ', ', $order_sql ); - if ( null !== $limit_position ) { + if ( $include_limit && null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } @@ -8037,9 +8129,10 @@ private function is_sql_calc_found_rows_select_query( string $query ): bool { * executes the paginated query itself while preserving compatible clauses. * * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. * @return string|null PostgreSQL query, or null when the query is unsupported. */ - private function translate_sql_calc_found_rows_select_query( string $query ): ?string { + private function translate_sql_calc_found_rows_select_query( string $query, bool $include_limit = true ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0], $tokens[1] ) @@ -8066,15 +8159,19 @@ private function translate_sql_calc_found_rows_select_query( string $query ): ?s $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( $tokens, 2, - $statement_end, + $select_end, false ); if ( null !== $contextual_sql ) { + if ( $include_limit && null !== $limit_position ) { + $contextual_sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + return $contextual_sql; } $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $select_end ); - if ( null !== $limit_position ) { + if ( $include_limit && null !== $limit_position ) { $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index a9a183038..3e696972b 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2344,13 +2344,20 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v 'sql' => 'SELECT 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 1 OFFSET 0', 'params' => array(), ), + array( + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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) AS "__wp_pg_found_rows"', + '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 result count. + * 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(); @@ -2358,20 +2365,51 @@ public function test_found_rows_returns_last_sql_calc_found_rows_count(): void { $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')" ); - $driver->query( + $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 0, 2" + LIMIT 1, 1" ); - $rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $rows = $driver->query( 'SELECT FOUND_ROWS()' ); - $this->assertSame( '2', $rows[0]->{'FOUND_ROWS()'} ); + $this->assertCount( 1, $page_rows ); + $this->assertSame( '2', $page_rows[0]->ID ); + $this->assertSame( '3', $rows[0]->{'FOUND_ROWS()'} ); $this->assertSame( array(), $driver->get_last_postgresql_queries() ); } + /** + * Tests non-SQL_CALC SELECT queries do not run FOUND_ROWS accounting. + */ + public function test_non_sql_calc_select_does_not_run_found_rows_accounting(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type) VALUES (1, 'post')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type) VALUES (2, 'post')" ); + + $rows = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY ID ASC LIMIT 0, 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID" FROM wptests_posts ORDER BY "ID" ASC LIMIT 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + } + /** * Tests DISTINCT SQL_CALC_FOUND_ROWS queries strip the modifier before PostgreSQL. */ @@ -2390,21 +2428,24 @@ public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_ord 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, 10"; + LIMIT 0, 1"; $rows = $driver->query( $select ); - $this->assertCount( 2, $rows ); + $this->assertCount( 1, $rows ); $this->assertSame( '2', $rows[0]->ID ); - $this->assertSame( '1', $rows[1]->ID ); $this->assertSame( array( 'ID' ), array_keys( get_object_vars( $rows[0] ) ) ); $queries = $driver->get_last_postgresql_queries(); - $this->assertCount( 1, $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 10 OFFSET 0', + '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 "__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) AS "__wp_pg_found_rows"', + $queries[1]['sql'] + ); $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); From d73766b48f10768eab603c8cdd43fcd4eebde6bf Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 16:12:04 +0000 Subject: [PATCH 071/142] Support PostgreSQL binary regex translation --- .../postgresql/class-wp-postgresql-driver.php | 116 ++++++++++++++++-- .../WP_PostgreSQL_Driver_RegExp_Tests.php | 83 ++++++++++++- 2 files changed, 189 insertions(+), 10 deletions(-) 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 index b9474b6a9..a4fc77fa6 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -10634,6 +10634,9 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_character_cast_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_binary_cast_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ); } @@ -11055,6 +11058,99 @@ private function is_mysql_character_cast_type( array $tokens, int $start, int $e && WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $start ]->id; } + /** + * Translate MySQL CAST(expr AS BINARY) to PostgreSQL text. + * + * PostgreSQL regex operators work on text, so keep supported binary regex + * predicates executable without broadening this lane to bytea emulation. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_binary_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_binary_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL binary CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_binary_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_binary_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL BINARY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_binary_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $start ]->id; + } + /** * Translate MySQL REGEXP/RLIKE operators to PostgreSQL regex operators. * @@ -11070,12 +11166,13 @@ private function translate_mysql_regexp_operator_to_postgresql( array $tokens, i if ( WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position ]->id - && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 1, $end ) ) { + $is_binary = $this->is_mysql_regexp_binary_predicate( $tokens, $position + 1, $end ); + return array( - 'sql' => '~*', + 'sql' => $is_binary ? '~' : '~*', 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, - 'position' => $position, + 'position' => $is_binary ? $position + 1 : $position, ); } @@ -11083,12 +11180,13 @@ private function translate_mysql_regexp_operator_to_postgresql( array $tokens, i isset( $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id && WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position + 1 ]->id - && ! $this->is_mysql_regexp_binary_predicate( $tokens, $position + 2, $end ) ) { + $is_binary = $this->is_mysql_regexp_binary_predicate( $tokens, $position + 2, $end ); + return array( - 'sql' => '!~*', + 'sql' => $is_binary ? '!~' : '!~*', 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, - 'position' => $position + 1, + 'position' => $is_binary ? $position + 2 : $position + 1, ); } @@ -11096,7 +11194,7 @@ private function translate_mysql_regexp_operator_to_postgresql( array $tokens, i } /** - * Check whether a REGEXP predicate starts with the unsupported BINARY modifier. + * Check whether a REGEXP predicate starts with the BINARY modifier. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param int $position First right-hand predicate token. @@ -12248,6 +12346,10 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_binary_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ) ) { return true; } 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 index dc54d3c6d..52fbcdf68 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -72,6 +72,14 @@ public function test_regexp_predicates_are_translated_to_postgresql_case_insensi "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'" + ) + ); } /** @@ -139,19 +147,88 @@ public function test_regexp_rewrite_does_not_replace_string_literals(): void { } /** - * Tests unsupported REGEXP BINARY predicates fall through visibly. + * Tests REGEXP BINARY and RLIKE BINARY predicates use case-sensitive PostgreSQL regex operators. */ - public function test_regexp_binary_predicate_is_not_silently_remapped(): void { + 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 REGEXP BINARY '^foo'", + "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 ); } /** From e9438fa4d51187c707f32443e1134387b01447d8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 16:46:48 +0000 Subject: [PATCH 072/142] Fix PostgreSQL backslash string quoting --- .../class-wp-postgresql-connection.php | 23 ++++++ ...greSQL_Connection_Pgsql_Quote_Fake_PDO.php | 21 ++++++ .../tests/WP_PostgreSQL_Connection_Tests.php | 33 +++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 72 +++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index 9c0a2f6c7..11d974e26 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -154,6 +154,15 @@ public function get_last_insert_id( ?string $sequence = null ): string { * @return string The quoted value. */ public function quote( $value, int $type = PDO::PARAM_STR ): string { + if ( + PDO::PARAM_STR === $type + && is_string( $value ) + && false !== strpos( $value, '\\' ) + && 'pgsql' === $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ) + ) { + return self::quote_escaped_string_value( $value ); + } + return $this->pdo->quote( $value, $type ); } @@ -203,4 +212,18 @@ private static function format_dsn_value( string $value ): string { return $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 { + return "E'" . str_replace( array( '\\', "'" ), array( '\\\\', "''" ), $value ) . "'"; + } } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php new file mode 100644 index 000000000..c930c6f16 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php @@ -0,0 +1,21 @@ +assertSame( $pdo->quote( "O'Reilly" ), $connection->quote( "O'Reilly" ) ); } + + /** + * Tests PostgreSQL string values with backslashes use escape string syntax. + */ + public function test_quote_uses_postgresql_escape_string_syntax_for_backslashes(): void { + $connection = $this->create_connection_with_pdo_fixture( new WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO() ); + + $this->assertSame( + "E'O''Reilly \\\\ path'", + $connection->quote( "O'Reilly \\ path" ) + ); + } + + /** + * Creates a PostgreSQL connection backed by a lightweight PDO fixture. + * + * @param object $pdo_fixture PDO-like fixture. + * @return WP_PostgreSQL_Connection Connection under test. + */ + private function create_connection_with_pdo_fixture( $pdo_fixture ): WP_PostgreSQL_Connection { + $reflection = new ReflectionClass( WP_PostgreSQL_Connection::class ); + $connection = $reflection->newInstanceWithoutConstructor(); + + $property = $reflection->getProperty( 'pdo' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( $connection, $pdo_fixture ); + + return $connection; + } } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 3e696972b..8c5e865a6 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1206,6 +1206,78 @@ public function test_simple_wordpress_update_with_backticks_is_translated_to_pos $this->assertSame( 'value2', $rows[0]->option_value ); } + /** + * 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\'', + '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\'', + '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. */ From a40d1417b3adb59a54857c355ba9460a9cae1017 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 17:20:50 +0000 Subject: [PATCH 073/142] Support grouped DISTINCT term ordering --- .../postgresql/class-wp-postgresql-driver.php | 569 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 133 +++- 2 files changed, 692 insertions(+), 10 deletions(-) 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 index a4fc77fa6..7fb1be83d 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -6058,9 +6058,6 @@ private function translate_strict_grouped_order_by_query( $archive_date_expression = $this->get_mysql_archive_grouped_date_expression_bounds( $tokens, $group_items ); $is_comment_id_group = $this->is_mysql_comment_id_grouped_select_shape( $tokens, $projection_items, $group_items ); $is_post_id_group = $this->is_mysql_post_id_grouped_select_shape( $tokens, $projection_items, $group_items ); - if ( null === $archive_date_expression && ! $is_comment_id_group && ! $is_post_id_group ) { - return null; - } if ( $has_distinct @@ -6074,6 +6071,22 @@ private function translate_strict_grouped_order_by_query( ) ) ) { + return $this->translate_distinct_strict_grouped_order_by_query( + $tokens, + $projection_start, + $projection_items, + $group_items, + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + if ( null === $archive_date_expression && ! $is_comment_id_group && ! $is_post_id_group ) { return null; } @@ -6162,6 +6175,556 @@ private function translate_strict_grouped_order_by_query( return $sql; } + /** + * Translate DISTINCT grouped queries that need hidden ORDER BY projections. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_distinct_strict_grouped_order_by_query( + array $tokens, + int $projection_start, + array $projection_items, + array $group_items, + array $order_items, + int $from_position, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): ?string { + $select_end = $limit_position ?? $statement_end; + if ( + $this->contains_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + ) + ) + ) { + return null; + } + + $has_hidden_order_expression = false; + foreach ( $order_items as $order_item ) { + if ( null === $order_item['projection_index'] ) { + $has_hidden_order_expression = true; + break; + } + } + + if ( ! $has_hidden_order_expression ) { + return null; + } + + if ( + ! $this->contains_mysql_aggregate_call( $tokens, $projection_start, $select_end ) + && $this->is_mysql_distinct_grouped_projection_shape( $tokens, $projection_items, $group_items ) + ) { + return $this->build_distinct_strict_grouped_order_by_query( + $tokens, + $projection_items, + $this->get_mysql_group_by_item_sql( $tokens, $group_items ), + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + $group_by_sql = $this->get_mysql_distinct_term_taxonomy_group_by_sql( + $tokens, + $projection_items, + $group_items, + $order_items, + $from_position, + $group_position + ); + if ( null === $group_by_sql ) { + return null; + } + + return $this->build_distinct_strict_grouped_order_by_query( + $tokens, + $projection_items, + $group_by_sql, + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + /** + * Translate parsed GROUP BY items to PostgreSQL SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return string[] PostgreSQL GROUP BY expressions. + */ + private function get_mysql_group_by_item_sql( array $tokens, array $group_items ): array { + $group_by_sql = array(); + foreach ( $group_items as $group_item ) { + $group_by_sql[] = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $group_item['start'], + $group_item['end'] + ); + } + + return $group_by_sql; + } + + /** + * Check whether DISTINCT projection expressions exactly match GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether grouping already preserves DISTINCT cardinality. + */ + private function is_mysql_distinct_grouped_projection_shape( array $tokens, array $projection_items, array $group_items ): bool { + if ( count( $projection_items ) !== count( $group_items ) ) { + return false; + } + + $matched_group_items = array(); + foreach ( $projection_items as $projection_item ) { + $matched = false; + foreach ( $group_items as $group_index => $group_item ) { + if ( isset( $matched_group_items[ $group_index ] ) ) { + continue; + } + + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + $matched_group_items[ $group_index ] = true; + $matched = true; + break; + } + } + + if ( ! $matched ) { + return false; + } + } + + return true; + } + + /** + * Get GROUP BY expressions for the supported single-taxonomy term query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @return string[]|null PostgreSQL GROUP BY expressions, or null when unsupported. + */ + private function get_mysql_distinct_term_taxonomy_group_by_sql( + array $tokens, + array $projection_items, + array $group_items, + array $order_items, + int $from_position, + int $group_position + ): ?array { + if ( + ! $this->is_mysql_distinct_term_taxonomy_projection_shape( $tokens, $projection_items ) + || ! $this->is_mysql_distinct_term_taxonomy_group_shape( $tokens, $group_items ) + || ! $this->is_mysql_distinct_term_taxonomy_order_shape( $tokens, $order_items ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + if ( + null === $where_position + || ! $this->is_mysql_distinct_term_taxonomy_from_shape( $tokens, $from_position, $where_position ) + || ! $this->has_mysql_single_term_taxonomy_predicate( $tokens, $where_position + 1, $group_position ) + ) { + return null; + } + + return array( + 't.term_id', + 'tt.term_taxonomy_id', + 'tt.taxonomy', + 'tt.description', + 'tt.parent', + ); + } + + /** + * Check whether the projection is WordPress's term query result shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @return bool Whether the projection shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_projection_shape( array $tokens, array $projection_items ): bool { + if ( 6 !== count( $projection_items ) ) { + return false; + } + + $expected_columns = array( + array( 't', 'term_id', 'term_id' ), + array( 'tt', 'term_taxonomy_id', 'term_taxonomy_id' ), + array( 'tt', 'taxonomy', 'taxonomy' ), + array( 'tt', 'description', 'description' ), + array( 'tt', 'parent', 'parent' ), + ); + foreach ( $expected_columns as $index => $expected_column ) { + if ( + ! $this->is_mysql_projection_item_qualified_column( + $tokens, + $projection_items[ $index ], + $expected_column[0], + $expected_column[1], + $expected_column[2] + ) + ) { + return false; + } + } + + return $this->is_mysql_count_post_type_projection_item( $tokens, $projection_items[5] ); + } + + /** + * Check whether a projection item is a specific qualified column. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @param string $alias Expected table alias. + * @param string $column Expected column name. + * @param string $name Expected output name. + * @return bool Whether the projection item matches. + */ + private function is_mysql_projection_item_qualified_column( array $tokens, array $item, string $alias, string $column, string $name ): bool { + return strtolower( $item['alias'] ) === $name + && $this->is_mysql_exact_qualified_column_expression( + $tokens, + $item['expression_start'], + $item['expression_end'], + $alias, + $column + ); + } + + /** + * Check whether a projection item is COUNT(p.post_type) AS count. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @return bool Whether the projection item matches. + */ + private function is_mysql_count_post_type_projection_item( array $tokens, array $item ): bool { + if ( 'count' !== strtolower( $item['alias'] ) ) { + return false; + } + + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $item['expression_start'], $item['expression_end'] ); + if ( + ! isset( $tokens[ $bounds['start'] ], $tokens[ $bounds['start'] + 1 ] ) + || ! $this->is_mysql_token_value( $tokens[ $bounds['start'] ], 'count' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $bounds['start'] + 1 ]->id + || $this->get_mysql_parenthesized_sequence_end( $tokens, $bounds['start'] + 1, $bounds['end'] ) !== $bounds['end'] + ) { + return false; + } + + return $this->is_mysql_exact_qualified_column_expression( + $tokens, + $bounds['start'] + 2, + $bounds['end'] - 1, + 'p', + 'post_type' + ); + } + + /** + * Check whether GROUP BY is exactly t.term_id. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the group shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_group_shape( array $tokens, array $group_items ): bool { + return 1 === count( $group_items ) + && $this->is_mysql_exact_qualified_column_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'], + 't', + 'term_id' + ); + } + + /** + * Check whether ORDER BY can be hidden for the supported term query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @return bool Whether the order shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_order_shape( array $tokens, array $order_items ): bool { + if ( 1 !== count( $order_items ) || null !== $order_items[0]['projection_index'] ) { + return false; + } + + return $this->is_mysql_exact_qualified_column_expression( + $tokens, + $order_items[0]['expression_start'], + $order_items[0]['expression_end'], + 't', + 'name' + ); + } + + /** + * Check whether FROM begins with terms t joined to term_taxonomy tt. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $from_position FROM token position. + * @param int $from_end Final FROM-clause token, exclusive. + * @return bool Whether the FROM shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_from_shape( array $tokens, int $from_position, int $from_end ): bool { + $terms_reference = $this->parse_mysql_table_reference( $tokens, $from_position + 1, $from_end ); + if ( + null === $terms_reference + || ! $this->is_mysql_wordpress_table_reference( $terms_reference, 'terms', 't' ) + ) { + return false; + } + + $position = $terms_reference['position']; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $term_taxonomy_reference = $this->parse_mysql_table_reference( $tokens, $position + 1, $from_end ); + if ( + null === $term_taxonomy_reference + || ! $this->is_mysql_wordpress_table_reference( $term_taxonomy_reference, 'term_taxonomy', 'tt' ) + ) { + return false; + } + + $position = $term_taxonomy_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $from_end ); + $pair = $this->get_mysql_top_level_simple_column_equality_pair( $tokens, $position + 1, $predicate_end ); + return null !== $pair && $this->is_mysql_wordpress_term_split_column_equality_pair( $pair ); + } + + /** + * Check whether WHERE constrains tt.taxonomy to one string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether a single taxonomy predicate is present. + */ + private function has_mysql_single_term_taxonomy_predicate( array $tokens, int $start, int $end ): bool { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return false; + } + + $matched = false; + foreach ( $conjuncts as $conjunct ) { + if ( ! $this->is_mysql_single_term_taxonomy_predicate( $tokens, $conjunct['start'], $conjunct['end'] ) ) { + continue; + } + + if ( $matched ) { + return false; + } + + $matched = true; + } + + return $matched; + } + + /** + * Check whether a predicate is tt.taxonomy = literal or IN (single literal). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token. + * @param int $end Final predicate token, exclusive. + * @return bool Whether the predicate constrains one taxonomy value. + */ + private function is_mysql_single_term_taxonomy_predicate( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] >= $end + || 'tt' !== strtolower( (string) $reference['qualifier'] ) + || 'taxonomy' !== strtolower( $reference['column'] ) + ) { + return false; + } + + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $reference['end'] ]->id + && $this->is_mysql_string_literal_range( $tokens, $reference['end'] + 1, $end ) + ) { + return true; + } + + if ( + WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $reference['end'] ]->id + || ! isset( $tokens[ $reference['end'] + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $reference['end'] + 1 ]->id + ) { + return false; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $end ); + if ( $after_close !== $end ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $end - 1 ); + return null !== $items + && 1 === count( $items ) + && $this->is_mysql_string_literal_range( $tokens, $items[0]['start'], $items[0]['end'] ); + } + + /** + * Check whether an expression is exactly a qualified column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $alias Expected table alias. + * @param string $column Expected column name. + * @return bool Whether the expression matches. + */ + private function is_mysql_exact_qualified_column_expression( array $tokens, int $start, int $end, string $alias, string $column ): bool { + $column_expression = $this->get_mysql_simple_qualified_column_expression( $tokens, $start, $end ); + return null !== $column_expression + && strtolower( $alias ) === $column_expression['qualifier'] + && strtolower( $column ) === $column_expression['column']; + } + + /** + * Build a derived-table rewrite for DISTINCT grouped ORDER BY queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param string[] $group_by_sql PostgreSQL GROUP BY expressions. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string PostgreSQL query. + */ + private function build_distinct_strict_grouped_order_by_query( + array $tokens, + array $projection_items, + array $group_by_sql, + array $order_items, + int $from_position, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): string { + $derived_table_alias = '__wp_pg_distinct'; + $quoted_derived_table_alias = $this->connection->quote_identifier( $derived_table_alias ); + $inner_projection_sql = array(); + $outer_projection_sql = array(); + + foreach ( $projection_items as $projection_item ) { + $quoted_alias = $this->connection->quote_identifier( $projection_item['alias'] ); + $inner_projection_sql[] = $projection_item['sql'] . ' AS ' . $quoted_alias; + $outer_projection_sql[] = sprintf( + '%s.%s AS %s', + $quoted_derived_table_alias, + $quoted_alias, + $quoted_alias + ); + } + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + continue; + } + + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + $quoted_order_alias = $this->connection->quote_identifier( $this->get_distinct_order_by_hidden_alias( $index ) ); + $inner_projection_sql[] = sprintf( + '%s(%s) AS %s', + $aggregate_function, + $order_item['sql'], + $quoted_order_alias + ); + } + + $sql = sprintf( + 'SELECT %s FROM (SELECT DISTINCT %s %s GROUP BY %s) AS %s ORDER BY %s', + implode( ', ', $outer_projection_sql ), + implode( ', ', $inner_projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $group_position ), + implode( ', ', $group_by_sql ), + $quoted_derived_table_alias, + $this->get_distinct_order_by_outer_order_sql( $projection_items, $order_items, $quoted_derived_table_alias ) + ); + + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + /** * Translate grouped SELECT queries that reference projection aliases in HAVING. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 8c5e865a6..45d5bd25c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2219,6 +2219,94 @@ public function test_distinct_term_id_order_by_name_preserves_visible_projection ); } + /** + * 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 SELECT DISTINCT term ID queries hide relationship order columns. */ @@ -2815,18 +2903,49 @@ public function test_decimal_cast_like_uses_text_without_changing_numeric_compar } /** - * Tests grouped DISTINCT ORDER BY shapes fail closed for later SELECT passes. + * Tests unsupported grouped DISTINCT ORDER BY shapes fail closed. */ - public function test_distinct_order_by_grouped_shape_fails_closed(): void { + public function test_distinct_grouped_order_by_unsupported_shapes_fail_closed(): void { $driver = $this->create_driver(); - $sql = $this->translate_driver_query_with_private_method( - $driver, - 'translate_distinct_order_by_query', - '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' + $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' + ), ); - $this->assertNull( $sql ); + 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, From 0308d0a46f89ad4c442b1a0ed07ff5925195ec21 Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 21:07:26 +0000 Subject: [PATCH 074/142] Fix PostgreSQL text quoting for feed upserts --- .../class-wp-postgresql-connection.php | 82 ++++++++++++- .../postgresql/class-wp-postgresql-driver.php | 56 ++++++++- ...greSQL_Connection_Pgsql_Quote_Fake_PDO.php | 4 + ...nnection_Pgsql_Quote_SQLite_Connection.php | 15 +++ .../tests/WP_PostgreSQL_Connection_Tests.php | 11 ++ .../tests/WP_PostgreSQL_Driver_Tests.php | 108 ++++++++++++++++++ 6 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index 11d974e26..15304dc9c 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -14,6 +14,15 @@ * without reusing SQLite-specific file, PRAGMA, and journal-mode handling. */ class WP_PostgreSQL_Connection { + /** + * Sentinel prefix for MySQL text bytes PostgreSQL text cannot store directly. + * + * PostgreSQL text rejects NUL bytes. Use a reversible private-use marker so + * MySQL string literals that decode to NUL can still round-trip through text + * columns. + */ + private const MYSQL_TEXT_SENTINEL = "\xEE\x80\x80"; + /** * The PDO connection for PostgreSQL. * @@ -157,15 +166,26 @@ public function quote( $value, int $type = PDO::PARAM_STR ): string { if ( PDO::PARAM_STR === $type && is_string( $value ) - && false !== strpos( $value, '\\' ) - && 'pgsql' === $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ) + && 'pgsql' === $this->get_driver_name() ) { - return self::quote_escaped_string_value( $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 ); } + /** + * Get the backing PDO driver name. + * + * @return string PDO driver name. + */ + protected function get_driver_name(): string { + return (string) $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + } + /** * Quote a PostgreSQL identifier. * @@ -213,6 +233,39 @@ private static function format_dsn_value( string $value ): string { 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" ) + && false === strpos( $value, self::MYSQL_TEXT_SENTINEL ) + ) { + return $value; + } + + return strtr( + $value, + array( + self::MYSQL_TEXT_SENTINEL => self::MYSQL_TEXT_SENTINEL . self::MYSQL_TEXT_SENTINEL, + "\0" => self::MYSQL_TEXT_SENTINEL . '0', + ) + ); + } + + /** + * 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. * @@ -224,6 +277,27 @@ private static function format_dsn_value( string $value ): string { * @return string PostgreSQL escaped string literal. */ private static function quote_escaped_string_value( string $value ): string { - return "E'" . str_replace( array( '\\', "'" ), array( '\\\\', "''" ), $value ) . "'"; + $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-driver.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php index 7fb1be83d..444796131 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -19,6 +19,11 @@ class WP_PostgreSQL_Driver { const DEFAULT_MYSQL_CHARSET = 'utf8mb4'; const DEFAULT_MYSQL_COLLATION = 'utf8mb4_unicode_ci'; + /** + * Sentinel prefix for MySQL text bytes PostgreSQL text cannot store directly. + */ + private const MYSQL_TEXT_SENTINEL = "\xEE\x80\x80"; + /** * PostgreSQL server version string. * @@ -480,7 +485,9 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $stmt->columnCount() > 0 ) { $this->last_column_meta = $this->normalize_column_meta( $stmt ); - $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( + $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) + ); if ( null !== $sql_calc_found_rows_query ) { $this->last_found_rows = $this->execute_sql_calc_found_rows_count_query( $sql_calc_found_rows_query ); } @@ -499,6 +506,53 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + /** + * Decode PostgreSQL-safe text sentinels in fetched result data. + * + * @param mixed $value Fetched result value. + * @return mixed MySQL-facing result value. + */ + private function decode_postgresql_text_for_mysql_in_result( $value ) { + if ( is_string( $value ) ) { + return self::decode_postgresql_text_for_mysql_value( $value ); + } + + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->decode_postgresql_text_for_mysql_in_result( $item ); + } + return $value; + } + + if ( is_object( $value ) ) { + foreach ( get_object_vars( $value ) as $key => $item ) { + $value->$key = $this->decode_postgresql_text_for_mysql_in_result( $item ); + } + } + + return $value; + } + + /** + * Decode MySQL text bytes previously encoded for PostgreSQL storage. + * + * @param string $value PostgreSQL text value. + * @return string MySQL-facing text value. + */ + private static function decode_postgresql_text_for_mysql_value( string $value ): string { + if ( false === strpos( $value, self::MYSQL_TEXT_SENTINEL ) ) { + return $value; + } + + return strtr( + $value, + array( + self::MYSQL_TEXT_SENTINEL . self::MYSQL_TEXT_SENTINEL => self::MYSQL_TEXT_SENTINEL, + self::MYSQL_TEXT_SENTINEL . '0' => "\0", + ) + ); + } + /** * Execute the unbounded count query for a SQL_CALC_FOUND_ROWS SELECT. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php index c930c6f16..872e68d2c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php @@ -16,6 +16,10 @@ public function __call( $method_name, array $arguments ) { return 'pgsql'; } + if ( 'quote' === $method_name ) { + return "'" . str_replace( "'", "''", (string) ( $arguments[0] ?? '' ) ) . "'"; + } + return null; } } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php new file mode 100644 index 000000000..5bc4d2dd4 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php @@ -0,0 +1,15 @@ +create_connection_with_pdo_fixture( new WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO() ); + + $quoted = $connection->quote( "protected\0property" ); + $this->assertStringNotContainsString( "\0", $quoted ); + $this->assertNotSame( "'protected\0property'", $quoted ); + } + /** * Creates a PostgreSQL connection backed by a lightweight PDO fixture. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 45d5bd25c..27fa7bda2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2,6 +2,8 @@ use PHPUnit\Framework\TestCase; +require_once __DIR__ . '/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php'; + /** * Unit tests for the PostgreSQL driver scaffold. */ @@ -36,6 +38,22 @@ public function test_query_returns_rows_and_metadata(): void { $this->assertArrayHasKey( 'mysqli:charsetnr', $column_meta[0] ); } + /** + * Tests fetched PostgreSQL-safe text decodes to MySQL NUL bytes. + */ + public function test_query_decodes_postgresql_text_sentinel_to_mysql_nul_byte(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + $connection = $driver->get_connection(); + + $driver->query( 'CREATE TABLE t (value TEXT NOT NULL)' ); + $connection->query( 'INSERT INTO t (value) VALUES (' . $connection->quote( "protected\0property" ) . ')' ); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( "protected\0property", $rows[0]->value ); + } + /** * Tests write queries return PDO row counts. */ @@ -1073,6 +1091,46 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $this->assertSame( 'no', $rows[0]->autoload ); } + /** + * Tests serialized feed option upserts quote PostgreSQL-safe text. + */ + public function test_options_upsert_quotes_serialized_feed_payload_for_postgresql(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $payload = serialize( + array( + "\0*\0data" => "single ' double \" backslash \\ marker E'\nnext line", + ) + ); + $insert = sprintf( + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('_transient_feed_quote_test', %s, 'off') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);", + $this->quote_mysql_string_literal_for_test( $payload ) + ); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $insert + ); + + $this->assertIsArray( $translation ); + $sql = $translation['sql']; + + $this->assertStringNotContainsString( "\0", $sql ); + $this->assertStringContainsString( 'E\'', $sql ); + $this->assertStringContainsString( '\\nnext line', $sql ); + $this->assertStringContainsString( '\\\\ marker', $sql ); + $this->assertStringContainsString( "single '' double", $sql ); + $this->assertStringContainsString( '"option_value" = excluded."option_value"', $sql ); + $this->assertStringContainsString( 'ON CONFLICT ("option_name") DO UPDATE', $sql ); + } + /** * Tests metadata-backed multi-row ON DUPLICATE KEY UPDATE statements. */ @@ -4603,6 +4661,36 @@ private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Dri return new WP_PostgreSQL_Driver( $connection, $db_name ); } + /** + * Creates a SQLite-backed driver that uses PostgreSQL quote translation. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_postgresql_quote_translation(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * 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. * @@ -4699,6 +4787,26 @@ function ( string $bound_method_name, string $bound_query ): ?string { 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 expected PostgreSQL SQL for MySQL-compatible integer casts. * From 13d7d8c79266cd6f46b5a82189a394ae5013590e Mon Sep 17 00:00:00 2001 From: adamziel Date: Thu, 11 Jun 2026 21:25:53 +0000 Subject: [PATCH 075/142] Fix PostgreSQL text sentinel decoding --- .../class-wp-postgresql-connection.php | 41 ++- .../postgresql/class-wp-postgresql-driver.php | 75 ++++- .../tests/WP_PostgreSQL_Connection_Tests.php | 1 + ..._Driver_Alter_Table_Fixture_Connection.php | 136 +++++++++ ...L_Driver_Show_Index_Fixture_Connection.php | 98 +++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 263 ++---------------- 6 files changed, 352 insertions(+), 262 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index 15304dc9c..ef7e2313c 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -15,13 +15,18 @@ */ class WP_PostgreSQL_Connection { /** - * Sentinel prefix for MySQL text bytes PostgreSQL text cannot store directly. + * Prefix for encoded MySQL text bytes PostgreSQL text cannot store directly. * - * PostgreSQL text rejects NUL bytes. Use a reversible private-use marker so + * PostgreSQL text rejects NUL bytes. Use a versioned whole-value envelope so * MySQL string literals that decode to NUL can still round-trip through text - * columns. + * columns without making short sentinel-like byte sequences ambiguous. */ - private const MYSQL_TEXT_SENTINEL = "\xEE\x80\x80"; + 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:'; /** * The PDO connection for PostgreSQL. @@ -240,20 +245,26 @@ private static function format_dsn_value( string $value ): string { * @return string PostgreSQL-safe text value. */ private static function encode_mysql_text_for_postgresql( string $value ): string { - if ( - false === strpos( $value, "\0" ) - && false === strpos( $value, self::MYSQL_TEXT_SENTINEL ) - ) { + if ( false === strpos( $value, "\0" ) && ! self::starts_with_mysql_text_encoding_prefix( $value ) ) { return $value; } - return strtr( - $value, - array( - self::MYSQL_TEXT_SENTINEL => self::MYSQL_TEXT_SENTINEL . self::MYSQL_TEXT_SENTINEL, - "\0" => self::MYSQL_TEXT_SENTINEL . '0', - ) - ); + 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 ); } /** 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 index 444796131..fbfb7ebd5 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -20,9 +20,14 @@ class WP_PostgreSQL_Driver { const DEFAULT_MYSQL_COLLATION = 'utf8mb4_unicode_ci'; /** - * Sentinel prefix for MySQL text bytes PostgreSQL text cannot store directly. + * Prefix for encoded MySQL text bytes PostgreSQL text cannot store directly. */ - private const MYSQL_TEXT_SENTINEL = "\xEE\x80\x80"; + 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:'; /** * PostgreSQL server version string. @@ -507,7 +512,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } /** - * Decode PostgreSQL-safe text sentinels in fetched result data. + * Decode PostgreSQL-safe text envelopes in fetched result data. * * @param mixed $value Fetched result value. * @return mixed MySQL-facing result value. @@ -540,17 +545,65 @@ private function decode_postgresql_text_for_mysql_in_result( $value ) { * @return string MySQL-facing text value. */ private static function decode_postgresql_text_for_mysql_value( string $value ): string { - if ( false === strpos( $value, self::MYSQL_TEXT_SENTINEL ) ) { + if ( 0 !== strpos( $value, self::MYSQL_TEXT_ENCODING_PREFIX ) ) { return $value; } - return strtr( - $value, - array( - self::MYSQL_TEXT_SENTINEL . self::MYSQL_TEXT_SENTINEL => self::MYSQL_TEXT_SENTINEL, - self::MYSQL_TEXT_SENTINEL . '0' => "\0", - ) - ); + $encoded = substr( $value, strlen( self::MYSQL_TEXT_ENCODING_PREFIX ) ); + $length_separator = strpos( $encoded, ':' ); + if ( false === $length_separator ) { + return $value; + } + + $length = substr( $encoded, 0, $length_separator ); + if ( ! self::is_canonical_decimal_string( $length ) ) { + return $value; + } + + $encoded = substr( $encoded, $length_separator + 1 ); + $hash_separator = strpos( $encoded, ':' ); + if ( false === $hash_separator ) { + return $value; + } + + $hash = substr( $encoded, 0, $hash_separator ); + $hex = substr( $encoded, $hash_separator + 1 ); + if ( + 1 !== preg_match( '/\A[0-9a-f]{64}\z/', $hash ) + || 0 !== strlen( $hex ) % 2 + || ! ctype_xdigit( $hex ) + ) { + return $value; + } + + $decoded = hex2bin( $hex ); + if ( + false === $decoded + || (string) strlen( $decoded ) !== $length + || ! hash_equals( $hash, hash( 'sha256', self::MYSQL_TEXT_ENCODING_HASH_CONTEXT . $decoded ) ) + ) { + return $value; + } + + return $decoded; + } + + /** + * Check whether a string is a canonical decimal integer. + * + * @param string $value String value. + * @return bool Whether the value is canonical decimal. + */ + private static function is_canonical_decimal_string( string $value ): bool { + if ( '' === $value ) { + return false; + } + + if ( '0' === $value ) { + return true; + } + + return '0' !== $value[0] && ctype_digit( $value ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index 84f05b347..315ebf2a2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -245,6 +245,7 @@ public function test_quote_encodes_mysql_text_nul_bytes_for_postgresql(): void { $quoted = $connection->quote( "protected\0property" ); $this->assertStringNotContainsString( "\0", $quoted ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $quoted ); $this->assertNotSame( "'protected\0property'", $quoted ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php new file mode 100644 index 000000000..01938b3c0 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php @@ -0,0 +1,136 @@ + new PDO( 'sqlite::memory:' ) ) ); + + if ( ! empty( $identity_metadata_rows ) ) { + $this->install_information_schema_marker(); + $this->install_identity_metadata_fixture( $identity_metadata_rows ); + $this->has_identity_metadata_fixture = true; + } + } + + /** + * Execute a query against PostgreSQL test fixtures when needed. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.pg_get_serial_sequence' ) ) { + return parent::query( + 'SELECT + column_name, + data_type, + is_identity, + column_default, + mysql_column_type, + mysql_extra, + sequence_schema, + sequence_name + FROM dml_identity_metadata_fixture + WHERE table_schema = ? + AND table_name = ? + ORDER BY ordinal_position', + array( $params[0] ?? '', $params[1] ?? '' ) + ); + } + + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.setval' ) ) { + ++$this->sequence_sync_query_count; + return parent::query( 'SELECT 1' ); + } + + if ( 0 === strpos( $sql, 'ALTER TABLE ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get the number of sequence repair queries executed. + * + * @return int Sequence repair query count. + */ + public function get_sequence_sync_query_count(): int { + return $this->sequence_sync_query_count; + } + + /** + * Install the information_schema marker used by the SQLite test shim. + */ + private function install_information_schema_marker(): void { + $pdo = $this->get_pdo(); + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( 'CREATE TABLE information_schema.columns (table_schema TEXT)' ); + } + + /** + * Install identity metadata rows. + * + * @param array[] $identity_metadata_rows Fixture identity metadata rows. + */ + private function install_identity_metadata_fixture( array $identity_metadata_rows ): void { + parent::query( + 'CREATE TABLE dml_identity_metadata_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + data_type TEXT NOT NULL, + is_identity TEXT NOT NULL, + column_default TEXT, + mysql_column_type TEXT, + mysql_extra TEXT NOT NULL, + sequence_schema TEXT, + sequence_name TEXT + )' + ); + + foreach ( $identity_metadata_rows as $row ) { + parent::query( + 'INSERT INTO dml_identity_metadata_fixture + (table_schema, table_name, column_name, ordinal_position, data_type, is_identity, column_default, mysql_column_type, mysql_extra, sequence_schema, sequence_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $row['table_schema'] ?? 'public', + $row['table_name'], + $row['column_name'], + $row['ordinal_position'] ?? 1, + $row['data_type'] ?? 'bigint', + $row['is_identity'] ?? 'YES', + $row['column_default'] ?? null, + $row['mysql_column_type'] ?? 'bigint(20)', + $row['mysql_extra'] ?? 'auto_increment', + $row['sequence_schema'] ?? 'public', + $row['sequence_name'], + ) + ); + } + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php new file mode 100644 index 000000000..e382c36c4 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php @@ -0,0 +1,98 @@ + new PDO( 'sqlite::memory:' ) ) ); + + $this->install_fixture(); + } + + /** + * Execute a query against the fixture when the PostgreSQL catalog query is used. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false === strpos( $sql, 'pg_catalog.pg_index' ) ) { + return parent::query( $sql, $params ); + } + + $fixture_sql = 'SELECT + table_name AS "Table", + non_unique AS "Non_unique", + key_name AS "Key_name", + seq_in_index AS "Seq_in_index", + column_name AS "Column_name", + collation AS "Collation", + cardinality AS "Cardinality", + sub_part AS "Sub_part", + packed AS "Packed", + nullable AS "Null", + index_type AS "Index_type", + comment AS "Comment", + index_comment AS "Index_comment", + visible AS "Visible", + expression AS "Expression" + FROM show_index_fixture + WHERE table_schema = ? + AND table_name = ?'; + $fixture_params = array( $params[0] ?? '', $params[1] ?? '' ); + + if ( isset( $params[2] ) ) { + $fixture_sql .= ' + AND key_name = ?'; + $fixture_params[] = $params[2]; + } + + $fixture_sql .= ' + ORDER BY sort_position, CAST(seq_in_index AS INTEGER)'; + + return parent::query( $fixture_sql, $fixture_params ); + } + + /** + * Install SHOW INDEX fixture rows into the injected PDO. + */ + private function install_fixture(): void { + $pdo = $this->get_pdo(); + + $pdo->exec( + 'CREATE TABLE show_index_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + sort_position INTEGER NOT NULL, + non_unique TEXT NOT NULL, + key_name TEXT NOT NULL, + seq_in_index TEXT NOT NULL, + column_name TEXT, + collation TEXT, + cardinality TEXT, + sub_part TEXT, + packed TEXT, + nullable TEXT NOT NULL, + index_type TEXT NOT NULL, + comment TEXT NOT NULL, + index_comment TEXT NOT NULL, + visible TEXT NOT NULL, + expression TEXT + )' + ); + $pdo->exec( + "INSERT INTO show_index_fixture + (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) + VALUES + ('public', 'wptests_options', 1, '0', 'PRIMARY', '1', 'option_id', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 2, '0', 'option_name', '1', 'option_name', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 3, '1', 'autoload', '1', 'autoload', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_posts', 4, '0', 'PRIMARY', '1', 'ID', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL)" + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 27fa7bda2..68d06c977 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2,6 +2,8 @@ use PHPUnit\Framework\TestCase; +require_once __DIR__ . '/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php'; +require_once __DIR__ . '/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php'; require_once __DIR__ . '/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php'; /** @@ -48,12 +50,35 @@ public function test_query_decodes_postgresql_text_sentinel_to_mysql_nul_byte(): $driver->query( 'CREATE TABLE t (value TEXT NOT NULL)' ); $connection->query( 'INSERT INTO t (value) VALUES (' . $connection->quote( "protected\0property" ) . ')' ); + $stored_rows = $connection->query( 'SELECT value FROM t' )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $stored_rows ); + $this->assertStringNotContainsString( "\0", $stored_rows[0]->value ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_rows[0]->value ); + $rows = $driver->query( 'SELECT value FROM t' ); $this->assertCount( 1, $rows ); $this->assertSame( "protected\0property", $rows[0]->value ); } + /** + * Tests external sentinel-shaped PostgreSQL text is preserved on fetch. + */ + public function test_query_preserves_external_postgresql_text_sentinel_collision_shape(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + $connection = $driver->get_connection(); + + $driver->query( 'CREATE TABLE t (value TEXT NOT NULL)' ); + + $external_value = 'pre' . "\xEE\x80\x80" . '0post'; + $connection->query( 'INSERT INTO t (value) VALUES (?)', array( $external_value ) ); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $external_value, $rows[0]->value ); + } + /** * Tests write queries return PDO row counts. */ @@ -1123,10 +1148,8 @@ public function test_options_upsert_quotes_serialized_feed_payload_for_postgresq $sql = $translation['sql']; $this->assertStringNotContainsString( "\0", $sql ); - $this->assertStringContainsString( 'E\'', $sql ); - $this->assertStringContainsString( '\\nnext line', $sql ); - $this->assertStringContainsString( '\\\\ marker', $sql ); - $this->assertStringContainsString( "single '' double", $sql ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $sql ); + $this->assertStringContainsString( bin2hex( $payload ), $sql ); $this->assertStringContainsString( '"option_value" = excluded."option_value"', $sql ); $this->assertStringContainsString( 'ON CONFLICT ("option_name") DO UPDATE', $sql ); } @@ -5266,235 +5289,3 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive ); } } - -/** - * Fixture connection that accepts PostgreSQL ALTER TABLE syntax in driver tests. - */ -class WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection extends WP_PostgreSQL_Connection { - /** - * Whether DML identity metadata rows are installed. - * - * @var bool - */ - private $has_identity_metadata_fixture = false; - - /** - * Number of sequence repair queries executed. - * - * @var int - */ - private $sequence_sync_query_count = 0; - - /** - * Constructor. - * - * @param array[] $identity_metadata_rows Optional fixture identity metadata rows. - */ - public function __construct( array $identity_metadata_rows = array() ) { - parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); - - if ( ! empty( $identity_metadata_rows ) ) { - $this->install_information_schema_marker(); - $this->install_identity_metadata_fixture( $identity_metadata_rows ); - $this->has_identity_metadata_fixture = true; - } - } - - /** - * Execute a query against PostgreSQL test fixtures when needed. - * - * @param string $sql SQL query. - * @param array $params Query parameters. - * @return PDOStatement Statement. - */ - public function query( string $sql, array $params = array() ): PDOStatement { - if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.pg_get_serial_sequence' ) ) { - return parent::query( - 'SELECT - column_name, - data_type, - is_identity, - column_default, - mysql_column_type, - mysql_extra, - sequence_schema, - sequence_name - FROM dml_identity_metadata_fixture - WHERE table_schema = ? - AND table_name = ? - ORDER BY ordinal_position', - array( $params[0] ?? '', $params[1] ?? '' ) - ); - } - - if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.setval' ) ) { - ++$this->sequence_sync_query_count; - return parent::query( 'SELECT 1' ); - } - - if ( 0 === strpos( $sql, 'ALTER TABLE ' ) ) { - return parent::query( 'SELECT 1 WHERE 0 = 1' ); - } - - return parent::query( $sql, $params ); - } - - /** - * Get the number of sequence repair queries executed. - * - * @return int Sequence repair query count. - */ - public function get_sequence_sync_query_count(): int { - return $this->sequence_sync_query_count; - } - - /** - * Install the information_schema marker used by the SQLite test shim. - */ - private function install_information_schema_marker(): void { - $pdo = $this->get_pdo(); - $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); - $pdo->exec( 'CREATE TABLE information_schema.columns (table_schema TEXT)' ); - } - - /** - * Install identity metadata rows. - * - * @param array[] $identity_metadata_rows Fixture identity metadata rows. - */ - private function install_identity_metadata_fixture( array $identity_metadata_rows ): void { - parent::query( - 'CREATE TABLE dml_identity_metadata_fixture ( - table_schema TEXT NOT NULL, - table_name TEXT NOT NULL, - column_name TEXT NOT NULL, - ordinal_position INTEGER NOT NULL, - data_type TEXT NOT NULL, - is_identity TEXT NOT NULL, - column_default TEXT, - mysql_column_type TEXT, - mysql_extra TEXT NOT NULL, - sequence_schema TEXT, - sequence_name TEXT - )' - ); - - foreach ( $identity_metadata_rows as $row ) { - parent::query( - 'INSERT INTO dml_identity_metadata_fixture - (table_schema, table_name, column_name, ordinal_position, data_type, is_identity, column_default, mysql_column_type, mysql_extra, sequence_schema, sequence_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( - $row['table_schema'] ?? 'public', - $row['table_name'], - $row['column_name'], - $row['ordinal_position'] ?? 1, - $row['data_type'] ?? 'bigint', - $row['is_identity'] ?? 'YES', - $row['column_default'] ?? null, - $row['mysql_column_type'] ?? 'bigint(20)', - $row['mysql_extra'] ?? 'auto_increment', - $row['sequence_schema'] ?? 'public', - $row['sequence_name'], - ) - ); - } - } -} - -/** - * Fixture connection for PostgreSQL SHOW INDEX catalog tests. - */ -class WP_PostgreSQL_Driver_Show_Index_Fixture_Connection extends WP_PostgreSQL_Connection { - /** - * Constructor. - */ - public function __construct() { - parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); - - $this->install_fixture(); - } - - /** - * Execute a query against the fixture when the PostgreSQL catalog query is used. - * - * @param string $sql SQL query. - * @param array $params Query parameters. - * @return PDOStatement Statement. - */ - public function query( string $sql, array $params = array() ): PDOStatement { - if ( false === strpos( $sql, 'pg_catalog.pg_index' ) ) { - return parent::query( $sql, $params ); - } - - $fixture_sql = 'SELECT - table_name AS "Table", - non_unique AS "Non_unique", - key_name AS "Key_name", - seq_in_index AS "Seq_in_index", - column_name AS "Column_name", - collation AS "Collation", - cardinality AS "Cardinality", - sub_part AS "Sub_part", - packed AS "Packed", - nullable AS "Null", - index_type AS "Index_type", - comment AS "Comment", - index_comment AS "Index_comment", - visible AS "Visible", - expression AS "Expression" - FROM show_index_fixture - WHERE table_schema = ? - AND table_name = ?'; - $fixture_params = array( $params[0] ?? '', $params[1] ?? '' ); - - if ( isset( $params[2] ) ) { - $fixture_sql .= ' - AND key_name = ?'; - $fixture_params[] = $params[2]; - } - - $fixture_sql .= ' - ORDER BY sort_position, CAST(seq_in_index AS INTEGER)'; - - return parent::query( $fixture_sql, $fixture_params ); - } - - /** - * Install SHOW INDEX fixture rows into the injected PDO. - */ - private function install_fixture(): void { - $pdo = $this->get_pdo(); - - $pdo->exec( - 'CREATE TABLE show_index_fixture ( - table_schema TEXT NOT NULL, - table_name TEXT NOT NULL, - sort_position INTEGER NOT NULL, - non_unique TEXT NOT NULL, - key_name TEXT NOT NULL, - seq_in_index TEXT NOT NULL, - column_name TEXT, - collation TEXT, - cardinality TEXT, - sub_part TEXT, - packed TEXT, - nullable TEXT NOT NULL, - index_type TEXT NOT NULL, - comment TEXT NOT NULL, - index_comment TEXT NOT NULL, - visible TEXT NOT NULL, - expression TEXT - )' - ); - $pdo->exec( - "INSERT INTO show_index_fixture - (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) - VALUES - ('public', 'wptests_options', 1, '0', 'PRIMARY', '1', 'option_id', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), - ('public', 'wptests_options', 2, '0', 'option_name', '1', 'option_name', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), - ('public', 'wptests_options', 3, '1', 'autoload', '1', 'autoload', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), - ('public', 'wptests_posts', 4, '0', 'PRIMARY', '1', 'ID', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL)" - ); - } -} From 2f4c06554f31d1c46445a83a99e55c46fb64f80c Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 01:16:01 +0000 Subject: [PATCH 076/142] Preserve PostgreSQL count aggregate result shape --- .../postgresql/class-wp-postgresql-driver.php | 111 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 39 ++++++ 2 files changed, 150 insertions(+) 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 index fbfb7ebd5..2c34bfe57 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -8895,6 +8895,15 @@ private function translate_mysql_compatible_query( string $query ): ?string { if ( null !== $contextual_sql ) { return $contextual_sql; } + + $contextual_sql = $this->translate_mysql_count_aggregate_projection_alias_query( + $tokens, + 1, + $statement_end + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } } if ( ! $this->needs_mysql_compatible_rewrite( $tokens, 0, $statement_end ) ) { @@ -8904,6 +8913,108 @@ private function translate_mysql_compatible_query( string $query ): ?string { return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); } + /** + * Add explicit aliases to multi-expression COUNT aggregate projections. + * + * PostgreSQL labels every unaliased COUNT expression as "count". WordPress + * later converts fetched objects to ARRAY_N by reading object properties, so + * duplicate labels collapse the result row before ARRAY_N can preserve order. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_count_aggregate_projection_alias_query( array $tokens, int $projection_start, int $statement_end ): ?string { + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection_ranges || count( $projection_ranges ) < 2 ) { + return null; + } + + $projection_sql = array(); + $alias_lookup = array(); + foreach ( $projection_ranges as $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $range['start'], + $range['end'] + ); + if ( + null === $expression_bounds + || $expression_bounds['start'] !== $range['start'] + || $expression_bounds['end'] !== $range['end'] + || ! $this->is_mysql_count_aggregate_expression( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ) + ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + $alias_key = strtolower( $expression_sql ); + if ( isset( $alias_lookup[ $alias_key ] ) ) { + return null; + } + + $alias_lookup[ $alias_key ] = true; + $projection_sql[] = sprintf( + '%s AS %s', + $expression_sql, + $this->connection->quote_identifier( $expression_sql ) + ); + } + + return sprintf( + 'SELECT %s %s', + implode( ', ', $projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + /** * Tokenize a MySQL query with the configured lexer implementation. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 68d06c977..b2611295a 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -3192,6 +3192,45 @@ public function test_aggregate_count_order_by_is_dropped_for_postgresql(): void ); } + /** + * 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. */ From ed04f5253ad8af5cd68d40a6dc78516653ada4d8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 02:15:24 +0000 Subject: [PATCH 077/142] Fix PostgreSQL term text lookups --- .../class-wp-postgresql-connection.php | 81 ++- .../postgresql/class-wp-postgresql-driver.php | 481 +++++++++++++++++- .../tests/WP_PostgreSQL_Connection_Tests.php | 142 ++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 126 +++++ 4 files changed, 826 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index ef7e2313c..e4551f5fe 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -42,6 +42,13 @@ class WP_PostgreSQL_Connection { */ private $query_logger; + /** + * Counter for generated statement savepoints. + * + * @var int + */ + private $savepoint_counter = 0; + /** * Constructor. * @@ -130,9 +137,28 @@ public function query( string $sql, array $params = array() ): PDOStatement { if ( $this->query_logger ) { ( $this->query_logger )( $sql, $params ); } - $stmt = $this->pdo->prepare( $sql ); - $stmt->execute( $params ); - return $stmt; + + $savepoint = $this->get_statement_savepoint_name( $sql ); + if ( null !== $savepoint ) { + $this->pdo->exec( 'SAVEPOINT ' . $savepoint ); + } + + try { + $stmt = $this->pdo->prepare( $sql ); + $stmt->execute( $params ); + + if ( null !== $savepoint ) { + $this->pdo->exec( 'RELEASE SAVEPOINT ' . $savepoint ); + } + + return $stmt; + } catch ( Throwable $exception ) { + if ( null !== $savepoint ) { + $this->rollback_statement_savepoint( $savepoint ); + } + + throw $exception; + } } /** @@ -219,6 +245,55 @@ public function set_query_logger( callable $logger ): void { $this->query_logger = $logger; } + /** + * 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. + * + * @return string|null Savepoint name, or null when no statement savepoint is needed. + */ + private function get_statement_savepoint_name( string $sql ): ?string { + if ( + 'pgsql' !== $this->get_driver_name() + || ! $this->pdo->inTransaction() + || $this->is_postgresql_transaction_control_statement( $sql ) + ) { + return null; + } + + ++$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 + ); + } + + /** + * Roll back and release a generated statement savepoint. + * + * @param string $savepoint Savepoint name. + */ + private function rollback_statement_savepoint( string $savepoint ): void { + try { + $this->pdo->exec( 'ROLLBACK TO SAVEPOINT ' . $savepoint ); + $this->pdo->exec( 'RELEASE SAVEPOINT ' . $savepoint ); + } catch ( Throwable $rollback_exception ) { + return; + } + } + /** * Formats a structured PostgreSQL DSN 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 index 2c34bfe57..faae873f9 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -1236,6 +1236,36 @@ private function get_mysql_table_column_type( return false === $column_type ? null : (string) $column_type; } + /** + * Get the stored MySQL collation for a table column. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string|null MySQL collation, or null when unavailable. + */ + private function get_mysql_table_column_collation( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT collation_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $collation = $stmt->fetchColumn(); + return false === $collation || null === $collation ? null : (string) $collation; + } + /** * Check whether stored MySQL metadata exists for a table. * @@ -7377,7 +7407,19 @@ private function is_mysql_wordpress_table_reference( array $reference, string $t return false; } - $table_name = strtolower( $reference['table'] ); + return $this->is_mysql_wordpress_table_name( $reference['table'], $table_base ); + } + + /** + * Check whether a table name matches a WordPress core table base name. + * + * @param string $table_name Table name. + * @param string $table_base Expected unprefixed table name. + * @return bool Whether the table name matches. + */ + private function is_mysql_wordpress_table_name( string $table_name, string $table_base ): bool { + $table_name = strtolower( $table_name ); + $table_base = strtolower( $table_base ); return $table_base === $table_name || substr( $table_name, -strlen( '_' . $table_base ) ) === '_' . $table_base; } @@ -9773,6 +9815,16 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( return $decimal_like; } + $wordpress_text_predicate = $this->translate_mysql_wordpress_text_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $wordpress_text_predicate ) { + return $wordpress_text_predicate; + } + $in_predicate = $this->translate_mysql_integer_column_string_in_predicate_to_postgresql( $tokens, $position, @@ -9811,6 +9863,387 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( ); } + /** + * Translate WordPress text predicates with MySQL case-insensitive collation semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + $like = $this->translate_mysql_wordpress_text_like_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $like ) { + return $like; + } + + $in = $this->translate_mysql_wordpress_text_in_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $in ) { + return $in; + } + + $comparison = $this->translate_mysql_wordpress_text_comparison_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $comparison ) { + return $comparison; + } + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + || ! $this->is_mysql_case_insensitive_equality_operator_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position + 2, $end ); + if ( + null === $reference + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s) %s LOWER(%s)', + $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ), + $tokens[ $position + 1 ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Translate a WordPress text LIKE predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate LIKE or NOT position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_like_predicate_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + ++$operator_position; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $operator_position ]->id + ) { + return null; + } + + $pattern = $this->get_mysql_string_like_pattern_sql( $tokens, $operator_position + 1, $end ); + if ( null === $pattern ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)%s LIKE LOWER(%s)%s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + $pattern['pattern_sql'], + $pattern['escape_sql'] + ), + 'position' => $pattern['end'] - 1, + ); + } + + /** + * Translate a WordPress text equality predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate comparison operator position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_comparison_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || $operator_position + 1 >= $end + || ! $this->is_mysql_case_insensitive_equality_operator_token( $tokens[ $operator_position ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $operator_position + 1 ] ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s) %s LOWER(%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $tokens[ $operator_position ]->get_bytes(), + $this->translate_mysql_token_to_postgresql( $tokens[ $operator_position + 1 ] ) + ), + 'position' => $operator_position + 1, + ); + } + + /** + * Translate a WordPress text IN predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate IN or NOT position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_in_predicate_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + ++$operator_position; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $operator_position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $operator_position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $operator_position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $operator_position + 2, $after_close - 1 ); + if ( empty( $items ) ) { + return null; + } + + $item_sql = array(); + foreach ( $items as $item ) { + if ( ! $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + return null; + } + + $item_sql[] = 'LOWER(' . $this->translate_mysql_token_to_postgresql( $tokens[ $item['start'] ] ) . ')'; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)%s IN (%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + implode( ', ', $item_sql ) + ), + 'position' => $after_close - 1, + ); + } + + /** + * Get a simple string LIKE pattern SQL fragment. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Pattern token position. + * @param int $end Final predicate token position, exclusive. + * @return array{pattern_sql: string, escape_sql: string, end: int}|null Pattern SQL, or null when unsupported. + */ + private function get_mysql_string_like_pattern_sql( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ] ) + || $position >= $end + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + ) { + return null; + } + + $pattern_sql = $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ); + $escape_sql = ''; + $pattern_end = $position + 1; + + if ( isset( $tokens[ $pattern_end ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_end ]->id ) { + if ( + ! isset( $tokens[ $pattern_end + 1 ] ) + || $pattern_end + 1 >= $end + || ! $this->is_mysql_string_literal_token( $tokens[ $pattern_end + 1 ] ) + ) { + return null; + } + + $escape_sql = ' ESCAPE ' . $this->translate_mysql_token_to_postgresql( $tokens[ $pattern_end + 1 ] ); + $pattern_end += 2; + } + + return array( + 'pattern_sql' => $pattern_sql, + 'escape_sql' => $escape_sql, + 'end' => $pattern_end, + ); + } + + /** + * Check whether a column is a case-insensitive WordPress text lookup column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference should use MySQL case-insensitive text predicates. + */ + private function is_mysql_case_insensitive_wordpress_text_column_reference( array $reference, array $scope ): bool { + $table = $this->get_mysql_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_case_insensitive_text_column( $table['table'], $reference['column'] ) ) { + return false; + } + + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + if ( null === $column_type || ! $this->is_mysql_text_family_column_type( $column_type ) ) { + return false; + } + + $collation = $this->get_mysql_column_collation_for_reference( $reference, $scope ); + return null !== $collation && $this->is_mysql_case_insensitive_collation( $collation ); + } + + /** + * Resolve a column reference to one table in the statement scope. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return array|null Table metadata, or null when missing/ambiguous. + */ + private function get_mysql_table_for_column_reference( array $reference, array $scope ): ?array { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + return $scope['aliases'][ $alias ] ?? null; + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_table = null; + foreach ( $scope['tables'] as $table ) { + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + if ( null === $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ) ) { + continue; + } + + if ( null !== $matched_table ) { + return null; + } + + $matched_table = $table; + } + + return $matched_table; + } + + /** + * Check whether a table/column pair is in a WordPress text lookup surface. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return bool Whether this is a supported text lookup column. + */ + private function is_mysql_wordpress_case_insensitive_text_column( string $table_name, string $column_name ): bool { + $column_name = strtolower( $column_name ); + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) ) { + return in_array( $column_name, array( 'post_content', 'post_excerpt', 'post_title' ), true ); + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'terms' ) ) { + return in_array( $column_name, array( 'name', 'slug' ), true ); + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'term_taxonomy' ) ) { + return in_array( $column_name, array( 'description', 'taxonomy' ), true ); + } + + return false; + } + + /** + * Check whether a MySQL collation is explicitly case-insensitive. + * + * @param string $collation MySQL collation name. + * @return bool Whether the collation is case-insensitive. + */ + private function is_mysql_case_insensitive_collation( string $collation ): bool { + $collation = strtolower( trim( $collation ) ); + return 1 === preg_match( '/(^|_)ci($|_)/', $collation ); + } + + /** + * Check whether a token is a case-insensitive equality operator candidate. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an equality or inequality operator. + */ + private function is_mysql_case_insensitive_equality_operator_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + ), + true + ); + } + /** * Translate a parenthesized SELECT predicate with inner and outer metadata scope. * @@ -10951,6 +11384,52 @@ private function get_mysql_column_type_for_reference( array $reference, array $s return $matched_type; } + /** + * Resolve a column reference to stored MySQL collation metadata. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null MySQL collation, or null when missing/ambiguous. + */ + private function get_mysql_column_collation_for_reference( array $reference, array $scope ): ?string { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + return $this->get_mysql_table_column_collation( $table['schema'], $table['table'], $reference['column'] ); + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_collation = null; + foreach ( $scope['tables'] as $table ) { + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + $collation = $this->get_mysql_table_column_collation( $table['schema'], $table['table'], $reference['column'] ); + if ( null === $collation ) { + continue; + } + + if ( null !== $matched_collation ) { + return null; + } + + $matched_collation = $collation; + } + + return $matched_collation; + } + /** * Check whether a token range is exactly one string literal. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index 315ebf2a2..4782d56fd 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -182,6 +182,58 @@ function ( string $sql, array $params ) use ( &$log ): void { $this->assertSame( array( array( 'SELECT ? AS value', array( 'ok' ) ) ), $log ); } + /** + * Tests failed statements are isolated from the active PostgreSQL transaction. + */ + public function test_query_rolls_back_failed_postgresql_statement_to_transaction_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + $connection->query( "INSERT INTO t (id, value) VALUES (1, 'ok')" ); + + try { + $connection->query( 'INSERT INTO missing_table (id) VALUES (1)' ); + $this->fail( 'Expected the invalid statement to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_table', $exception->getMessage() ); + } + + $stmt = $connection->query( 'SELECT value FROM t WHERE id = 1' ); + + $this->assertSame( 'ok', $stmt->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + 'ROLLBACK TO SAVEPOINT wp_statement_3', + 'RELEASE SAVEPOINT wp_statement_3', + 'SAVEPOINT wp_statement_4', + 'RELEASE SAVEPOINT wp_statement_4', + ), + $pdo->exec_sql + ); + } + + /** + * Tests transaction-control statements are not wrapped in generated savepoints. + */ + public function test_query_does_not_wrap_transaction_control_statement_in_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'ROLLBACK;' ); + + $this->assertFalse( $pdo->inTransaction() ); + $this->assertSame( array(), $pdo->exec_sql ); + } + /** * Tests prepare returns a PDO statement and logs without parameters. */ @@ -268,3 +320,93 @@ private function create_connection_with_pdo_fixture( $pdo_fixture ): WP_PostgreS return $connection; } } + +/** + * PDO-like fixture that records statement savepoint commands. + */ +class WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO { + /** + * Recorded exec() SQL. + * + * @var string[] + */ + public $exec_sql = array(); + + /** + * SQLite PDO used for real statement execution. + * + * @var PDO + */ + private $pdo; + + /** + * Constructor. + */ + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Begin a transaction. + * + * @return bool Whether the transaction started. + */ + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->beginTransaction(); + } + + /** + * Roll back the active transaction. + * + * @return bool Whether the transaction was rolled back. + */ + public function rollBack(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->rollBack(); + } + + /** + * Check whether a transaction is active. + * + * @return bool Whether a transaction is active. + */ + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->inTransaction(); + } + + /** + * Prepare a SQL statement. + * + * @param string $sql SQL statement. + * @return PDOStatement Statement object. + */ + public function prepare( string $sql ): PDOStatement { + 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_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index b2611295a..9a2c734b3 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2073,6 +2073,132 @@ static function ( $row ): string { ); } + /** + * 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 ambiguous unqualified integer references do not guess a table. */ From 963927c031de201b2c7ff1e127faf55ce58be86b Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 02:33:10 +0000 Subject: [PATCH 078/142] Prevent schema-qualified predicate suffix rewrites --- .../postgresql/class-wp-postgresql-driver.php | 18 ++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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 index faae873f9..8b07d202a 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -9715,6 +9715,10 @@ private function translate_mysql_predicate_token_sequence_to_postgresql( $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 @@ -9863,6 +9867,20 @@ private function translate_mysql_integer_column_string_predicate_to_postgresql( ); } + /** + * Check whether a scanner position is inside a qualified reference suffix. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First predicate token position. + * @return bool Whether the position follows a dot in the same predicate. + */ + private function is_mysql_qualified_reference_suffix_position( array $tokens, int $position, int $start ): bool { + return $position > $start + && isset( $tokens[ $position - 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id; + } + /** * Translate WordPress text predicates with MySQL case-insensitive collation semantics. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 9a2c734b3..892aed2fe 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2199,6 +2199,35 @@ static function ( $row ): string { ); } + /** + * Tests schema-qualified WordPress text predicates do not rewrite qualified-reference suffixes. + */ + public function test_schema_qualified_wordpress_text_predicates_fail_closed_without_suffix_rewrite(): void { + $driver = $this->create_driver(); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + + foreach ( + array( + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name = 'BURRITO'", + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name LIKE '%Bur%'", + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name IN ('BURRITO')", + ) as $query + ) { + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ); + + if ( null !== $sql ) { + $this->assertSame( $query, $sql ); + } + $this->assertStringNotContainsString( 'public. LOWER(', (string) $sql ); + } + } + /** * Tests ambiguous unqualified integer references do not guess a table. */ From 23bf3ad6d65d1e69beba27c5ec6643b6a520629c Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 03:23:48 +0000 Subject: [PATCH 079/142] Normalize PostgreSQL date DML values --- .../postgresql/class-wp-postgresql-driver.php | 352 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 88 +++++ 2 files changed, 421 insertions(+), 19 deletions(-) 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 index 8b07d202a..005dd3a62 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3811,12 +3811,23 @@ private function translate_simple_mysql_replace_query( string $query ): ?array { } ++$position; - $values = $this->parse_mysql_value_list( $tokens, $position ); - if ( null === $values || count( $columns ) !== count( $values ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + $parsed_values = $this->parse_mysql_value_list_with_ranges( $tokens, $position ); + if ( null === $parsed_values || count( $columns ) !== count( $parsed_values['values'] ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { return null; } - $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values ); + $values = $parsed_values['values']; + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata( $table_name ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $parsed_values['ranges'], + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values, $column_metadata ); $sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', @@ -3985,12 +3996,23 @@ private function translate_simple_mysql_insert_query( string $query ): ?array { } ++$position; - $values = $this->parse_mysql_value_list( $tokens, $position ); - if ( null === $values || count( $columns ) !== count( $values ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + $parsed_values = $this->parse_mysql_value_list_with_ranges( $tokens, $position ); + if ( null === $parsed_values || count( $columns ) !== count( $parsed_values['values'] ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { return null; } - $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values ); + $values = $parsed_values['values']; + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata( $table_name ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $parsed_values['ranges'], + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values, $column_metadata ); $sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', @@ -4360,11 +4382,12 @@ private function translate_simple_mysql_update_query( string $query ): ?string { /** * Append metadata-derived defaults for omitted NOT NULL columns in non-strict DML. * - * @param string $table_name Table name. - * @param string[] $columns DML columns, mutated when defaults are appended. - * @param string[] $values DML values, mutated when defaults are appended. + * @param string $table_name Table name. + * @param string[] $columns DML columns, mutated when defaults are appended. + * @param string[] $values DML values, mutated when defaults are appended. + * @param array|null $column_metadata Optional ordered column metadata rows. */ - private function append_non_strict_dml_defaults_for_omitted_columns( string $table_name, array &$columns, array &$values ): void { + private function append_non_strict_dml_defaults_for_omitted_columns( string $table_name, array &$columns, array &$values, ?array $column_metadata = null ): void { if ( $this->is_mysql_strict_sql_mode_active() ) { return; } @@ -4374,13 +4397,17 @@ private function append_non_strict_dml_defaults_for_omitted_columns( string $tab $supplied_columns[ strtolower( (string) $column ) ] = true; } - foreach ( $this->get_mysql_dml_column_metadata( $table_name ) as $column_metadata ) { - $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + + foreach ( $column_metadata as $column_metadata_row ) { + $column_name = (string) ( $column_metadata_row['column_name'] ?? '' ); if ( '' === $column_name || isset( $supplied_columns[ strtolower( $column_name ) ] ) ) { continue; } - $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata ); + $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata_row ); if ( null === $default_sql ) { continue; } @@ -4429,7 +4456,6 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a return null; } - $value_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment_end ); $target_column_key = strtolower( $target_column ); $target_metadata = $column_metadata[ $target_column_key ] ?? null; $coerced_default_sql = null; @@ -4438,10 +4464,18 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a $coerced_default_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); } + $value_sql = $coerced_default_sql; + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql ) { + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment_end ); + } + $assignments[] = sprintf( '%s = %s', $this->connection->quote_identifier( $target_column ), - null === $coerced_default_sql ? $value_sql : $coerced_default_sql + $value_sql ); $position = $assignment_end; @@ -4455,6 +4489,245 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a return count( $assignments ) > 0 ? implode( ', ', $assignments ) : null; } + /** + * Normalize non-strict DML values using MySQL column metadata. + * + * @param string[] $columns DML columns. + * @param string[] $values Translated DML values, mutated when needed. + * @param array[] $value_ranges Original token ranges for each value. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $metadata Ordered column metadata rows. + */ + private function normalize_non_strict_mysql_dml_values_for_columns( array $columns, array &$values, array $value_ranges, array $tokens, array $metadata ): void { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( + ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ] ) + || ! isset( $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) + ) { + continue; + } + + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( + $column_metadata[ $column_key ], + $tokens, + (int) $value_ranges[ $index ]['start'], + (int) $value_ranges[ $index ]['end'] + ); + if ( null !== $value_sql ) { + $values[ $index ] = $value_sql; + } + } + } + + /** + * Get a non-strict MySQL-compatible DML value for a column when special handling is needed. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when generic translation is sufficient. + */ + private function get_non_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + return $this->get_non_strict_mysql_dml_date_time_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + + /** + * Get a non-strict MySQL-compatible date/time literal for a column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the literal does not need normalization. + */ + private function get_non_strict_mysql_dml_date_time_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $start ] ) + ) { + return null; + } + + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + $value = $tokens[ $start ]->get_value(); + $storage_value = $this->get_non_strict_mysql_dml_date_time_storage_value( $base_type, $value ); + if ( null === $storage_value || $storage_value === $value ) { + return null; + } + + return $this->connection->quote( $storage_value ); + } + + /** + * Get the non-strict MySQL storage value for a date/time literal. + * + * @param string $base_type Base MySQL date/time column type. + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not date/time-shaped. + */ + private function get_non_strict_mysql_dml_date_time_storage_value( string $base_type, string $value ): ?string { + if ( 'date' === $base_type ) { + return $this->get_non_strict_mysql_dml_date_storage_value( $value ); + } + + return $this->get_non_strict_mysql_dml_datetime_storage_value( $value ); + } + + /** + * Get the non-strict MySQL storage value for a DATE literal. + * + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not date-shaped. + */ + private function get_non_strict_mysql_dml_date_storage_value( string $value ): ?string { + $parts = $this->get_mysql_dml_date_parts( $value ); + if ( null === $parts ) { + return null; + } + + if ( $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) ) { + return $value; + } + + if ( checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ) ) { + return $value; + } + + return '0000-00-00'; + } + + /** + * Get the non-strict MySQL storage value for a DATETIME/TIMESTAMP literal. + * + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not datetime-shaped. + */ + private function get_non_strict_mysql_dml_datetime_storage_value( string $value ): ?string { + $normalized_value = $this->normalize_mysql_dml_datetime_literal_format( $value ); + $parts = $this->get_mysql_dml_datetime_parts( $normalized_value ); + if ( null === $parts ) { + return null; + } + + $is_valid_time = $this->is_mysql_dml_time_value_valid( $parts['hour'], $parts['minute'], $parts['second'] ); + if ( + $is_valid_time + && $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) + ) { + return $normalized_value; + } + + if ( + $is_valid_time + && checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ) + ) { + return $normalized_value; + } + + return '0000-00-00 00:00:00'; + } + + /** + * Normalize MySQL-accepted ISO datetime literals to the stored MySQL text shape. + * + * @param string $value Unquoted literal value. + * @return string Normalized literal value. + */ + private function normalize_mysql_dml_datetime_literal_format( string $value ): string { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2})Z$/', $value, $matches ) ) { + return $matches[1] . ' ' . $matches[2]; + } + + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2})$/', $value, $matches ) ) { + return $matches[1] . ' ' . $matches[2]; + } + + return $value; + } + + /** + * Check whether a zero or partial-zero date is permitted in non-strict mode. + * + * @param string $year Four-digit year. + * @param string $month Two-digit month. + * @param string $day Two-digit day. + * @return bool Whether MySQL permits storing the zero date parts. + */ + private function is_non_strict_mysql_dml_zero_date_allowed( string $year, string $month, string $day ): bool { + if ( '0000' === $year && '00' === $month && '00' === $day ) { + return true; + } + + return '0000' !== $year + && ( '00' === $month || '00' === $day ) + && ! $this->is_mysql_sql_mode_active( 'NO_ZERO_IN_DATE' ); + } + + /** + * Get date parts from a MySQL DATE literal. + * + * @param string $value Unquoted literal value. + * @return array{year: string, month: string, day: string}|null Date parts, or null when not date-shaped. + */ + private function get_mysql_dml_date_parts( string $value ): ?array { + if ( 1 !== preg_match( '/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/', $value, $matches ) ) { + return null; + } + + return array( + 'year' => $matches[1], + 'month' => $matches[2], + 'day' => $matches[3], + ); + } + + /** + * Get date and time parts from a MySQL DATETIME/TIMESTAMP literal. + * + * @param string $value Unquoted literal value. + * @return array{year: string, month: string, day: string, hour: string, minute: string, second: string}|null Date/time parts, or null when not datetime-shaped. + */ + private function get_mysql_dml_datetime_parts( string $value ): ?array { + if ( 1 !== preg_match( '/^([0-9]{4})-([0-9]{2})-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2})$/', $value, $matches ) ) { + return null; + } + + return array( + 'year' => $matches[1], + 'month' => $matches[2], + 'day' => $matches[3], + 'hour' => $matches[4], + 'minute' => $matches[5], + 'second' => $matches[6], + ); + } + + /** + * Check whether a MySQL DATETIME/TIMESTAMP time part is valid. + * + * @param string $hour Two-digit hour. + * @param string $minute Two-digit minute. + * @param string $second Two-digit second. + * @return bool Whether the time part is valid. + */ + private function is_mysql_dml_time_value_valid( string $hour, string $minute, string $second ): bool { + return (int) $hour <= 23 + && (int) $minute <= 59 + && (int) $second <= 59; + } + /** * Get DML column metadata keyed by lowercase column name. * @@ -4462,8 +4735,20 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a * @return array Column metadata lookup. */ private function get_mysql_dml_column_metadata_lookup( string $table_name ): array { + return $this->get_mysql_dml_column_metadata_lookup_from_rows( + $this->get_mysql_dml_column_metadata( $table_name ) + ); + } + + /** + * Get DML column metadata keyed by lowercase column name from existing rows. + * + * @param array[] $metadata Column metadata rows. + * @return array Column metadata lookup. + */ + private function get_mysql_dml_column_metadata_lookup_from_rows( array $metadata ): array { $lookup = array(); - foreach ( $this->get_mysql_dml_column_metadata( $table_name ) as $column_metadata ) { + foreach ( $metadata as $column_metadata ) { $column_name = (string) ( $column_metadata['column_name'] ?? '' ); if ( '' !== $column_name ) { $lookup[ strtolower( $column_name ) ] = $column_metadata; @@ -4658,6 +4943,23 @@ private function is_mysql_strict_sql_mode_active(): bool { return false; } + /** + * Check whether a MySQL session SQL mode is active. + * + * @param string $mode SQL mode name. + * @return bool Whether the mode is active. + */ + private function is_mysql_sql_mode_active( string $mode ): bool { + $mode = strtoupper( $mode ); + foreach ( explode( ',', $this->sql_mode ) as $active_mode ) { + if ( strtoupper( trim( $active_mode ) ) === $mode ) { + return true; + } + } + + return false; + } + /** * Translate simple single-table MySQL SELECT statements to PostgreSQL. * @@ -9124,15 +9426,16 @@ private function parse_mysql_identifier_list( array $tokens, int &$position ): ? * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param int $position Current token position, updated on success. - * @return string[]|null Translated SQL values, or null when unsupported. + * @return array{values: string[], ranges: array[]}|null Translated SQL values and token ranges, or null when unsupported. */ - private function parse_mysql_value_list( array $tokens, int &$position ): ?array { + private function parse_mysql_value_list_with_ranges( array $tokens, int &$position ): ?array { if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { return null; } ++$position; $values = array(); + $ranges = array(); $value_start = $position; $depth = 0; @@ -9150,8 +9453,15 @@ private function parse_mysql_value_list( array $tokens, int &$position ): ?array } $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); ++$position; - return $values; + return array( + 'values' => $values, + 'ranges' => $ranges, + ); } --$depth; @@ -9165,6 +9475,10 @@ private function parse_mysql_value_list( array $tokens, int &$position ): ?array } $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); $value_start = $position + 1; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 892aed2fe..910532051 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -208,6 +208,35 @@ public function test_non_strict_insert_appends_omitted_not_null_defaults_from_my $this->assertSame( '0', $posts[0]->post_parent ); } + /** + * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_insert_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $insert = "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) VALUES (1, '2020-12-41 14:15:27', '0000-00-00 00:00:00', '2020-00-15 14:15:27', '2020-06-01T12:13:14Z')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") VALUES (1, \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\', \'2020-00-15 14:15:27\', \'2020-06-01 12:13:14\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date, post_date_gmt, post_modified, post_modified_gmt FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $posts ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date_gmt ); + $this->assertSame( '2020-00-15 14:15:27', $posts[0]->post_modified ); + $this->assertSame( '2020-06-01 12:13:14', $posts[0]->post_modified_gmt ); + } + /** * Tests strict SQL mode leaves omitted NOT NULL INSERT columns to fail visibly. */ @@ -1388,6 +1417,38 @@ public function test_non_strict_update_null_coerces_not_null_columns_to_metadata $this->assertSame( 'yes', $rows[0]->autoload ); } + /** + * Tests non-strict UPDATE normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_update_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03')" + ); + + $update = "UPDATE `wptests_posts` SET `post_date_gmt` = '2020-02-31 14:15:27', `post_modified_gmt` = '2020-07-04T01:02:03Z' WHERE `ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_posts" SET "post_date_gmt" = \'0000-00-00 00:00:00\', "post_modified_gmt" = \'2020-07-04 01:02:03\' WHERE "ID" = 1', + '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. */ @@ -4779,6 +4840,33 @@ private function install_options_table_with_mysql_metadata( WP_PostgreSQL_Driver ); } + /** + * Install a PostgreSQL-like posts table with MySQL datetime metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_posts_datetime_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_date TEXT NOT NULL, + post_date_gmt TEXT NOT NULL, + post_modified TEXT NOT NULL, + post_modified_gmt TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_date_gmt datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified_gmt timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (ID) + )" + ); + } + /** * Install a term relationships table with MySQL composite key metadata. * From e923eb05b4c25145b478b7818a13a0193db717ca Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 03:56:16 +0000 Subject: [PATCH 080/142] Preserve PostgreSQL comment column lengths --- .../tests/WP_PostgreSQL_DB_Tests.php | 260 ++++++++++++++++++ .../postgresql/class-wp-postgresql-db.php | 90 +++++- 2 files changed, 344 insertions(+), 6 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index fcabd0a61..90861dab9 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -786,6 +786,266 @@ public function get_queries(): array { ); } + /** + * Tests length checks prefer MySQL column metadata over widened PostgreSQL storage. + */ + public function test_get_col_length_uses_mysql_declared_metadata_before_postgresql_storage(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Column_Length_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + if ( array( 'wptests_temp_comments' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $this->queries[] = 'native_columns:' . ( $params[0] ?? '' ); + + if ( array( 'wptests_native_text' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'body', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + if ( array( 'wptests_comments' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'comment_author', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + } + + if ( false !== strpos( $sql, 'SELECT data_type, character_maximum_length' ) ) { + $this->queries[] = 'direct_length:' . implode( ':', $params ); + return $this->statement_from_rows( + array( + array( + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Column_Length_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Column_Length_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 'SHOW FULL COLUMNS FROM `wptests_comments`' !== $query ) { + return array(); + } + + $rows = array( + array( + 'Field' => 'comment_author', + 'Type' => 'varchar(245)', + 'Collation' => 'utf8mb4_unicode_ci', + ), + array( + 'Field' => 'comment_content', + 'Type' => 'longtext', + 'Collation' => 'utf8mb4_unicode_ci', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + return $rows; + } + + return array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Column_Length_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Column_Length_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + "CREATE TEMPORARY TABLE wptests_temp_comments ( + comment_author varchar(245) NOT NULL default '', + comment_content longtext NOT NULL + ) DEFAULT CHARACTER SET utf8mb4" +); + +wp_postgresql_db_test_respond( + array( + 'temporary_comment_author_length' => $db->get_col_length( 'wptests_temp_comments', 'comment_author' ), + 'comment_author_length' => $db->get_col_length( 'wptests_comments', 'comment_author' ), + 'comment_content_length' => $db->get_col_length( 'wptests_comments', 'comment_content' ), + 'native_text_length' => $db->get_col_length( 'wptests_native_text', 'body' ), + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 245, + ), + $result['temporary_comment_author_length'], + 'Temporary CREATE TABLE metadata should preserve declared MySQL varchar length.' + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 245, + ), + $result['comment_author_length'], + 'Declared MySQL varchar length should win over PostgreSQL text storage.' + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 4294967295, + ), + $result['comment_content_length'] + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 65535, + ), + $result['native_text_length'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_temp_comments', + 'temp_schema:wptests_comments', + 'metadata_exists', + 'temp_schema:wptests_comments', + 'metadata_exists', + 'temp_schema:wptests_native_text', + 'metadata_exists', + 'native_columns:wptests_native_text', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_comments`', + 'SHOW FULL COLUMNS FROM `wptests_comments`', + 'SHOW FULL COLUMNS FROM `wptests_native_text`', + ), + $result['driver_queries'] + ); + } + /** * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. */ diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 59471ef61..cedd04363 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -245,11 +245,18 @@ public function get_col_length( $table, $column ) { return false; } - $table = trim( (string) $table, "`\" \t\n\r\0\x0B" ); - $column = trim( (string) $column, "`\" \t\n\r\0\x0B" ); - if ( false !== strpos( $table, '.' ) ) { - $table = substr( $table, strrpos( $table, '.' ) + 1 ); - $table = trim( $table, "`\" \t\n\r\0\x0B" ); + $table = $this->normalize_postgresql_table_name( (string) $table ); + $column = trim( (string) $column, "`\" \t\n\r\0\x0B" ); + $columnkey = $this->get_postgresql_metadata_key( (string) $column ); + + $columns = $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; + } } try { @@ -885,7 +892,7 @@ private function get_postgresql_column_charset_metadata( string $table ) { */ private function get_postgresql_temporary_table_schema( string $table ) { try { - $stmt = $this->dbh->get_connection()->query( + $stmt = $this->dbh->get_connection()->query( 'SELECT n.nspname FROM pg_catalog.pg_class c INNER JOIN pg_catalog.pg_namespace n @@ -1073,6 +1080,77 @@ private function format_postgresql_charset_column_rows( array $rows ): array { 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. * From 52372d21671fdcd7119a69a425c36c62a313b2e2 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 04:28:19 +0000 Subject: [PATCH 081/142] Stabilize PostgreSQL sticky post ordering --- .../postgresql/class-wp-postgresql-driver.php | 134 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 77 +++++++++- 2 files changed, 207 insertions(+), 4 deletions(-) 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 index 005dd3a62..5a7b5114f 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -5079,6 +5079,16 @@ private function translate_simple_mysql_select_query( string $query ): ?string { if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $select_end ) { $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); } + + $tiebreaker_sql = $this->get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( + $tokens, + $table_name, + $order_position, + $select_end + ); + if ( null !== $tiebreaker_sql ) { + $sql .= ', ' . $tiebreaker_sql; + } } if ( null !== $limit_position ) { @@ -9839,7 +9849,17 @@ private function translate_mysql_select_statement_with_integer_string_coercion( $tokens, $order_position + 2, $order_end, - $scope + $scope, + ! $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + ) + ) ); if ( $order_sql['changed'] ) { $replacements[] = array( @@ -9903,13 +9923,15 @@ private function translate_mysql_token_sequence_with_replacements_to_postgresql( * @param int $start First ORDER BY item token position. * @param int $end Final ORDER BY token position, exclusive. * @param array $scope Statement table scope. + * @param bool $allow_wordpress_posts_post_date_tiebreaker Whether to add the WordPress posts date tie-breaker. * @return array{sql: string, changed: bool} Translated ORDER BY SQL and change flag. */ private function translate_mysql_order_by_token_sequence_to_postgresql( array $tokens, int $start, int $end, - array $scope + array $scope, + bool $allow_wordpress_posts_post_date_tiebreaker ): array { $order_items = $this->parse_mysql_select_order_by_items( $tokens, $start, $end, array(), $scope ); if ( null === $order_items ) { @@ -9932,6 +9954,14 @@ private function translate_mysql_order_by_token_sequence_to_postgresql( $order_sql[] = $item_sql; } + $tiebreaker_sql = $allow_wordpress_posts_post_date_tiebreaker + ? $this->get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, $order_items, $scope ) + : null; + if ( null !== $tiebreaker_sql ) { + $order_sql[] = $tiebreaker_sql; + $changed = true; + } + return array( 'sql' => $changed ? implode( ', ', $order_sql ) @@ -9940,6 +9970,86 @@ private function translate_mysql_order_by_token_sequence_to_postgresql( ); } + /** + * Get the MySQL-compatible posts date tie-breaker for a simple SELECT. + * + * WordPress's posts table has the MySQL type_status_date index ending in ID. + * MySQL scans that index backward for default post_date DESC queries, so rows + * with equal post_date values are returned by descending ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param string $table_name Selected table name. + * @param int $order_position ORDER token position. + * @param int $end Final ORDER BY token position, exclusive. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( + array $tokens, + string $table_name, + int $order_position, + int $end + ): ?string { + if ( + ! $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) + || $order_position + 4 !== $end + || ! $this->is_mysql_identifier_like_token_value( $tokens[ $order_position + 2 ] ?? null, 'post_date' ) + || ! isset( $tokens[ $order_position + 3 ] ) + || WP_MySQL_Lexer::DESC_SYMBOL !== $tokens[ $order_position + 3 ]->id + ) { + return null; + } + + return $this->connection->quote_identifier( 'ID' ) . ' DESC'; + } + + /** + * Get the MySQL-compatible posts date tie-breaker for a parsed ORDER BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( array $tokens, array $order_items, array $scope ): ?string { + if ( + 1 !== count( $order_items ) + || ! empty( $scope['unknown'] ) + || 1 !== count( $scope['tables'] ) + || 'DESC' !== $order_items[0]['direction'] + ) { + return null; + } + + $order_item = $order_items[0]; + $reference = $this->parse_mysql_column_reference( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + if ( + null === $reference + || $reference['end'] !== $order_item['expression_end'] + || 'post_date' !== strtolower( $reference['column'] ) + ) { + return null; + } + + $table = $this->get_mysql_single_scope_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_table_name( $table['table'], 'posts' ) ) { + return null; + } + + if ( null !== $reference['qualifier'] ) { + return sprintf( + '%s.%s DESC', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['start'] + 1 ), + $this->connection->quote_identifier( 'ID' ) + ); + } + + return $this->connection->quote_identifier( 'ID' ) . ' DESC'; + } + /** * Translate expression tokens with metadata-backed numeric text coercions. * @@ -10523,6 +10633,26 @@ private function get_mysql_table_for_column_reference( array $reference, array $ return $matched_table; } + /** + * Resolve a column reference when a statement scope has exactly one table. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return array|null Table metadata, or null when missing/ambiguous. + */ + private function get_mysql_single_scope_table_for_column_reference( array $reference, array $scope ): ?array { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + return $scope['aliases'][ $alias ] ?? null; + } + + if ( ! empty( $scope['unknown'] ) || 1 !== count( $scope['tables'] ) ) { + return null; + } + + return $scope['tables'][0]; + } + /** * Check whether a table/column pair is in a WordPress text lookup surface. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 910532051..51f875e9b 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1817,6 +1817,79 @@ public function test_field_function_returns_zero_for_null_and_missing_values(): $this->assertSame( '2', $rows[0]->alpha_position ); } + /** + * Tests WordPress sticky base queries get MySQL's posts date ID tie-breaker. + */ + public function test_wordpress_posts_post_date_desc_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 5; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 5"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '5', '4', '3', '2', '1' ), + array_map( + static function ( $row ) { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" 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(), + ), + array( + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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, wptests_posts."ID" DESC) AS "__wp_pg_found_rows"', + '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" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' ORDER BY wptests_posts.post_date ASC LIMIT 5 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\' ORDER BY wptests_posts.post_date ASC) AS "__wp_pg_found_rows"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + /** * Tests integer-column IN predicates coerce string literals using stored MySQL metadata. */ @@ -2798,11 +2871,11 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v $this->assertSame( array( array( - 'sql' => 'SELECT 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 1 OFFSET 0', + 'sql' => 'SELECT 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, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', 'params' => array(), ), array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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, wptests_posts."ID" DESC) AS "__wp_pg_found_rows"', 'params' => array(), ), ), From f8157dc0aedb957368c7787d65958c3cf842f800 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 05:00:34 +0000 Subject: [PATCH 082/142] Fix PostgreSQL search relevance ordering --- .../postgresql/class-wp-postgresql-driver.php | 88 +++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 63 +++++++++++++ 2 files changed, 151 insertions(+) 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 index 5a7b5114f..ff3b336f1 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -10088,6 +10088,15 @@ private function translate_mysql_expression_token_sequence_to_postgresql( $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; } @@ -10119,6 +10128,85 @@ private function translate_mysql_expression_token_sequence_to_postgresql( ); } + /** + * Translate WordPress text predicates embedded in expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_expression_predicate_to_postgresql( + array $tokens, + int $position, + int $start, + int $end, + array $scope + ): ?array { + if ( ! $this->is_mysql_expression_predicate_start_context( $tokens, $position, $start ) ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null === $reference + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return $this->translate_mysql_wordpress_text_like_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + } + + /** + * Check whether an expression position starts a boolean predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First expression token position. + * @return bool Whether the candidate follows a boolean expression boundary. + */ + private function is_mysql_expression_predicate_start_context( array $tokens, int $position, int $start ): bool { + if ( $position <= $start ) { + return false; + } + + $previous_token_id = $tokens[ $position - 1 ]->id ?? null; + if ( $this->is_mysql_expression_predicate_left_boundary_token_id( $previous_token_id ) ) { + return true; + } + + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && $position - 1 > $start + && $this->is_mysql_expression_predicate_left_boundary_token_id( $tokens[ $position - 2 ]->id ?? null ); + } + + /** + * Check whether a token can precede a predicate inside an expression. + * + * @param int|null $token_id MySQL token ID. + * @return bool Whether the token is a predicate boundary. + */ + private function is_mysql_expression_predicate_left_boundary_token_id( ?int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::WHEN_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + /** * Translate predicate tokens with metadata-backed integer string coercion. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 51f875e9b..bcae32f72 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2333,6 +2333,69 @@ static function ( $row ): string { ); } + /** + * 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. */ From efa6caa7db51f53f0ad94a42a9c4635c0e145e3e Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 05:23:54 +0000 Subject: [PATCH 083/142] Preserve postmeta value LIKE collation --- .../postgresql/class-wp-postgresql-driver.php | 11 ++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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 index ff3b336f1..d9fcc16b6 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -10672,6 +10672,13 @@ private function is_mysql_case_insensitive_wordpress_text_column_reference( arra return false; } + if ( + $this->is_mysql_wordpress_table_name( $table['table'], 'postmeta' ) + && null === $reference['qualifier'] + ) { + return false; + } + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); if ( null === $column_type || ! $this->is_mysql_text_family_column_type( $column_type ) ) { return false; @@ -10763,6 +10770,10 @@ private function is_mysql_wordpress_case_insensitive_text_column( string $table_ return in_array( $column_name, array( 'description', 'taxonomy' ), true ); } + if ( $this->is_mysql_wordpress_table_name( $table_name, 'postmeta' ) ) { + return 'meta_value' === $column_name; + } + return false; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index bcae32f72..41d8dcce2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -3158,6 +3158,64 @@ static function ( $row ): string { $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); } + /** + * Tests grouped postmeta value-only queries preserve MySQL case-insensitive LIKE behavior. + */ + public function test_grouped_postmeta_value_like_without_key_uses_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (3, 'post', 'publish', '2024-01-03 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'address', '123 Lorem St.')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'city', 'Loren')" ); + + $rows = $driver->query( + "SELECT wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( ( wptests_postmeta.meta_value LIKE '%lorem%' ) ) + AND wptests_posts.post_type = 'post' + AND ( ( wptests_posts.post_status = 'publish' ) ) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 5" + ); + + $this->assertSame( + array( '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertStringContainsString( + "LOWER(wptests_postmeta.meta_value) LIKE LOWER('%lorem%')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + /** * Tests numeric literals in predicate context use MySQL truthiness. */ From f2083ba47c93bf61101010401e4725687a80f703 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 05:47:13 +0000 Subject: [PATCH 084/142] Return changed rows for PostgreSQL updates --- .../postgresql/class-wp-postgresql-driver.php | 47 +++++++++++++----- .../tests/WP_PostgreSQL_Driver_Tests.php | 48 ++++++++++++++++--- 2 files changed, 78 insertions(+), 17 deletions(-) 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 index d9fcc16b6..91b86e291 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -4348,17 +4348,18 @@ private function translate_simple_mysql_update_query( string $query ): ?string { return null; } - $set_sql = $this->translate_simple_mysql_update_set_clause( $table_name, $tokens, $position, $set_end ); - if ( null === $set_sql ) { + $update_set_clause = $this->translate_simple_mysql_update_set_clause( $table_name, $tokens, $position, $set_end ); + if ( null === $update_set_clause ) { return null; } $sql = sprintf( 'UPDATE %s SET %s', $this->connection->quote_identifier( $table_name ), - $set_sql + $update_set_clause['set_sql'] ); + $where_sql = null; if ( null !== $where_position ) { if ( $where_position + 1 >= $statement_end @@ -4373,7 +4374,17 @@ private function translate_simple_mysql_update_query( string $query ): ?string { $statement_end, $this->get_mysql_single_table_scope( $table_name ) ); - $sql .= ' WHERE ' . $where_sql['sql']; + $where_sql = $where_sql['sql']; + } + + if ( null !== $where_sql ) { + $sql .= sprintf( + ' WHERE (%s) AND (%s)', + $where_sql, + $update_set_clause['changed_predicate_sql'] + ); + } else { + $sql .= ' WHERE ' . $update_set_clause['changed_predicate_sql']; } return $sql; @@ -4426,13 +4437,14 @@ private function append_non_strict_dml_defaults_for_omitted_columns( string $tab * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param int $start First SET-clause token position. * @param int $end Final SET-clause token position, exclusive. - * @return string|null PostgreSQL SET SQL, or null when unsupported. + * @return array{set_sql: string, changed_predicate_sql: string}|null PostgreSQL SET data, or null when unsupported. */ - private function translate_simple_mysql_update_set_clause( string $table_name, array $tokens, int $start, int $end ): ?string { - $column_metadata = $this->is_mysql_strict_sql_mode_active() + private function translate_simple_mysql_update_set_clause( string $table_name, array $tokens, int $start, int $end ): ?array { + $column_metadata = $this->is_mysql_strict_sql_mode_active() ? array() : $this->get_mysql_dml_column_metadata_lookup( $table_name ); - $assignments = array(); + $assignments = array(); + $changed_predicates = array(); for ( $position = $start; $position < $end; ) { $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); @@ -4472,9 +4484,15 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a $value_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment_end ); } - $assignments[] = sprintf( + $quoted_target_column = $this->connection->quote_identifier( $target_column ); + $assignments[] = sprintf( '%s = %s', - $this->connection->quote_identifier( $target_column ), + $quoted_target_column, + $value_sql + ); + $changed_predicates[] = sprintf( + '%s IS DISTINCT FROM (%s)', + $quoted_target_column, $value_sql ); @@ -4486,7 +4504,14 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a ++$position; } - return count( $assignments ) > 0 ? implode( ', ', $assignments ) : null; + if ( 0 === count( $assignments ) ) { + return null; + } + + return array( + 'set_sql' => implode( ', ', $assignments ), + 'changed_predicate_sql' => implode( ' OR ', $changed_predicates ), + ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 41d8dcce2..b780cf25f 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1303,7 +1303,7 @@ public function test_simple_wordpress_update_with_backticks_is_translated_to_pos $this->assertSame( array( array( - 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\' WHERE "option_name" = \'key1\'', + 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\' WHERE ("option_name" = \'key1\') AND ("option_value" IS DISTINCT FROM (\'value2\'))', 'params' => array(), ), ), @@ -1316,6 +1316,42 @@ public function test_simple_wordpress_update_with_backticks_is_translated_to_pos $this->assertSame( 'value2', $rows[0]->option_value ); } + /** + * Tests simple WordPress UPDATE statements return changed rows, not matched rows. + */ + public function test_simple_wordpress_update_returns_zero_for_noop_update(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_parent INTEGER NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_comments (comment_ID, comment_parent, comment_content) VALUES (1, 0, 'first')" ); + + $update = "UPDATE `wp_comments` SET `comment_parent` = 2, `comment_content` = 'updated' WHERE `comment_ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_comments" SET "comment_parent" = 2, "comment_content" = \'updated\' WHERE ("comment_ID" = 1) AND ("comment_parent" IS DISTINCT FROM (2) OR "comment_content" IS DISTINCT FROM (\'updated\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT comment_parent, comment_content FROM wp_comments WHERE "comment_ID" = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->comment_parent ); + $this->assertSame( 'updated', $rows[0]->comment_content ); + } + /** * Tests simple UPDATE preserves a MySQL literal ending in an escaped backslash. */ @@ -1339,7 +1375,7 @@ public function test_simple_update_preserves_trailing_escaped_backslash_literal( $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\'', + '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(), ), ), @@ -1375,7 +1411,7 @@ public function test_simple_update_literal_placeholder_bytes_are_not_bound_param $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\'', + '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(), ), ), @@ -1403,7 +1439,7 @@ public function test_non_strict_update_null_coerces_not_null_columns_to_metadata $this->assertSame( array( array( - 'sql' => 'UPDATE "wptests_options" SET "option_value" = \'\', "autoload" = \'yes\' WHERE "option_name" = \'cron\'', + 'sql' => 'UPDATE "wptests_options" SET "option_value" = \'\', "autoload" = \'yes\' WHERE ("option_name" = \'cron\') AND ("option_value" IS DISTINCT FROM (\'\') OR "autoload" IS DISTINCT FROM (\'yes\'))', 'params' => array(), ), ), @@ -1435,7 +1471,7 @@ public function test_non_strict_update_normalizes_invalid_date_time_literals_fro $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', + '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(), ), ), @@ -1589,7 +1625,7 @@ public function test_multi_assignment_wordpress_update_with_backticks_is_transla $this->assertSame( array( array( - 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\', "autoload" = \'yes\' WHERE "option_name" = \'key1\'', + '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(), ), ), From a99de2ff8909e71f28ebc729da73573680a298ec Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 06:18:01 +0000 Subject: [PATCH 085/142] Add page search order tie-breaker --- .../postgresql/class-wp-postgresql-driver.php | 81 +++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 49 +++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) 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 index 91b86e291..babf078c4 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -9948,7 +9948,7 @@ private function translate_mysql_token_sequence_with_replacements_to_postgresql( * @param int $start First ORDER BY item token position. * @param int $end Final ORDER BY token position, exclusive. * @param array $scope Statement table scope. - * @param bool $allow_wordpress_posts_post_date_tiebreaker Whether to add the WordPress posts date tie-breaker. + * @param bool $allow_wordpress_posts_id_tiebreaker Whether to add WordPress posts ID tie-breakers. * @return array{sql: string, changed: bool} Translated ORDER BY SQL and change flag. */ private function translate_mysql_order_by_token_sequence_to_postgresql( @@ -9956,7 +9956,7 @@ private function translate_mysql_order_by_token_sequence_to_postgresql( int $start, int $end, array $scope, - bool $allow_wordpress_posts_post_date_tiebreaker + 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 ) { @@ -9979,9 +9979,13 @@ private function translate_mysql_order_by_token_sequence_to_postgresql( $order_sql[] = $item_sql; } - $tiebreaker_sql = $allow_wordpress_posts_post_date_tiebreaker - ? $this->get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, $order_items, $scope ) - : null; + $tiebreaker_sql = null; + if ( $allow_wordpress_posts_id_tiebreaker ) { + $tiebreaker_sql = $this->get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, $order_items, $scope ); + if ( null === $tiebreaker_sql ) { + $tiebreaker_sql = $this->get_wordpress_posts_menu_order_title_order_id_tiebreaker_sql( $tokens, $order_items, $scope ); + } + } if ( null !== $tiebreaker_sql ) { $order_sql[] = $tiebreaker_sql; $changed = true; @@ -10075,6 +10079,73 @@ private function get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( arr return $this->connection->quote_identifier( 'ID' ) . ' DESC'; } + /** + * Get the MySQL-compatible posts title tie-breaker for admin page searches. + * + * MySQL returns tied page rows for WordPress's menu_order/title ordering in + * primary-key order. PostgreSQL may return those ties in physical order, + * which changes the parent group selected by WP_Posts_List_Table paging. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_wordpress_posts_menu_order_title_order_id_tiebreaker_sql( array $tokens, array $order_items, array $scope ): ?string { + if ( + 2 !== count( $order_items ) + || ! empty( $scope['unknown'] ) + || 1 !== count( $scope['tables'] ) + ) { + return null; + } + + $expected_columns = array( 'menu_order', 'post_title' ); + $references = array(); + $matched_table = null; + foreach ( $expected_columns as $index => $expected_column ) { + if ( 'ASC' !== $order_items[ $index ]['direction'] ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( + $tokens, + $order_items[ $index ]['expression_start'], + $order_items[ $index ]['expression_end'] + ); + if ( + null === $reference + || $reference['end'] !== $order_items[ $index ]['expression_end'] + || strtolower( $reference['column'] ) !== $expected_column + ) { + return null; + } + + $table = $this->get_mysql_single_scope_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_table_name( $table['table'], 'posts' ) ) { + return null; + } + + if ( null !== $matched_table && $matched_table !== $table ) { + return null; + } + + $matched_table = $table; + $references[] = $reference; + } + + $qualifier_reference = null !== $references[0]['qualifier'] ? $references[0] : $references[1]; + if ( null !== $qualifier_reference['qualifier'] ) { + return sprintf( + '%s.%s ASC', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $qualifier_reference['start'], $qualifier_reference['start'] + 1 ), + $this->connection->quote_identifier( 'ID' ) + ); + } + + return $this->connection->quote_identifier( 'ID' ) . ' ASC'; + } + /** * Translate expression tokens with metadata-backed numeric text coercions. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index b780cf25f..dacf49819 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1926,6 +1926,55 @@ public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebrea ); } + /** + * 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, wptests_posts.post_title ASC, wptests_posts."ID" ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + /** * Tests integer-column IN predicates coerce string literals using stored MySQL metadata. */ From b4eaaeb70ba29fd61e2df02a05b4474c8518b87f Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 06:54:18 +0000 Subject: [PATCH 086/142] Order available post MIME types for PostgreSQL --- .../postgresql/class-wp-postgresql-driver.php | 78 +++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 69 ++++++++++++++++ 2 files changed, 147 insertions(+) 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 index babf078c4..878408a6d 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -455,6 +455,12 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $translated_query = $this->translate_wordpress_available_post_mime_types_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -5801,6 +5807,78 @@ private function translate_distinct_order_by_query( string $query, bool $include ); } + /** + * Translate WordPress's available post MIME type lookup with MySQL order. + * + * WordPress issues this query without ORDER BY, but MySQL returns MIME types + * in first matching posts.ID order for the posts table shape. Keep this + * constrained to the exact get_available_post_mime_types() query so generic + * unordered DISTINCT queries remain unchanged. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_available_post_mime_types_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[12] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( 13 !== $statement_end ) { + return null; + } + + if ( + ! $this->is_mysql_identifier_like_token_value( $tokens[2], 'post_mime_type' ) + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[3]->id + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[5]->id + || ! $this->is_mysql_identifier_like_token_value( $tokens[6], 'post_type' ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[7]->id + || ! $this->is_mysql_string_literal_token( $tokens[8] ) + || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[9]->id + || ! $this->is_mysql_identifier_like_token_value( $tokens[10], 'post_mime_type' ) + || WP_MySQL_Lexer::NOT_EQUAL_OPERATOR !== $tokens[11]->id + || ! $this->is_mysql_string_literal_token( $tokens[12] ) + || '' !== $tokens[12]->get_value() + ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[4] ); + if ( null === $table_name || ! $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name ); + $table = $scope['tables'][0]; + foreach ( array( 'ID', 'post_mime_type', 'post_type' ) as $column_name ) { + if ( null === $this->get_mysql_table_column_type( $table['schema'], $table['table'], $column_name ) ) { + return null; + } + } + + $projection_sql = $this->translate_mysql_token_to_postgresql( $tokens[2] ); + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + 6, + $statement_end, + $scope + ); + + return sprintf( + 'SELECT %1$s %2$s WHERE %3$s GROUP BY %1$s ORDER BY MIN(%4$s) ASC', + $projection_sql, + 'FROM ' . $this->translate_mysql_token_to_postgresql( $tokens[4] ), + $where_sql['sql'], + $this->connection->quote_identifier( 'ID' ) + ); + } + /** * Check whether a token is an unsupported SELECT modifier for this rewrite. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index dacf49819..44b95b422 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1975,6 +1975,75 @@ static function ( $row ): string { ); } + /** + * Tests available post MIME type lookups use MySQL-compatible first posts.ID ordering. + */ + public function test_wordpress_available_post_mime_types_distinct_orders_by_first_post_id(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (1, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (2, 'attachment', 'application/pdf')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (3, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (4, 'post', 'text/plain')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (5, 'attachment', '')" ); + + $rows = $driver->query( + "SELECT DISTINCT post_mime_type + FROM wptests_posts + WHERE post_type = 'attachment' AND post_mime_type != ''" + ); + + $this->assertSame( + array( 'image/jpeg', 'application/pdf' ), + array_map( + static function ( $row ): string { + return $row->post_mime_type; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_mime_type FROM wptests_posts WHERE post_type = \'attachment\' AND post_mime_type != \'\' GROUP BY post_mime_type ORDER BY MIN("ID") ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests broader posts DISTINCT MIME type queries keep the generic path. + */ + public function test_wordpress_available_post_mime_types_distinct_rewrite_requires_exact_where_shape(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_available_post_mime_types_query', + "SELECT DISTINCT post_mime_type FROM wptests_posts WHERE post_type = 'attachment'" + ); + + $this->assertNull( $sql ); + } + /** * Tests integer-column IN predicates coerce string literals using stored MySQL metadata. */ From ba60bce0a1b27e316531b3654465690935b63fe1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 08:18:06 +0000 Subject: [PATCH 087/142] Preserve explicit PostgreSQL insert IDs --- .../postgresql/class-wp-postgresql-driver.php | 197 +++++++++++++++++- ...tion_Stale_Insert_ID_SQLite_Connection.php | 22 ++ .../tests/WP_PostgreSQL_Driver_Tests.php | 76 ++++++- 3 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection.php 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 index 878408a6d..2ef534c40 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -85,6 +85,13 @@ class WP_PostgreSQL_Driver { */ private $last_found_rows = 0; + /** + * MySQL-compatible insert ID for the last successful insert-like query. + * + * @var int|string + */ + private $last_insert_id = 0; + /** * MySQL-compatible session SQL mode state. * @@ -174,13 +181,7 @@ public function get_last_postgresql_queries(): array { * @return int|string */ public function get_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; + return is_numeric( $this->last_insert_id ) ? (int) $this->last_insert_id : $this->last_insert_id; } /** @@ -511,6 +512,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } 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 ); } @@ -4038,6 +4040,187 @@ private function translate_simple_mysql_insert_query( string $query ): ?array { ); } + /** + * Store a MySQL-compatible insert ID after a successful insert-like query. + * + * PostgreSQL PDO exposes the sequence value, which can be stale when the + * caller explicitly supplies an AUTO_INCREMENT value. MySQL reports that + * explicit value through mysqli_insert_id(), and WordPress relies on it. + * + * @param array $dml_query Translated DML query metadata. + * @param int $affected_rows Backend affected row count. + */ + private function set_last_insert_id_after_dml_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + $this->last_insert_id = 0; + return; + } + + if ( isset( $dml_query['inserted_new_row'] ) && ! $dml_query['inserted_new_row'] ) { + $this->last_insert_id = 0; + return; + } + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + $this->last_insert_id = $this->get_connection_last_insert_id(); + return; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( (string) $dml_query['table_name'] ); + if ( empty( $metadata_lookup ) ) { + $this->last_insert_id = $this->get_connection_last_insert_id(); + return; + } + + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + $this->last_insert_id = 0; + return; + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( + $auto_increment_column, + $dml_query['columns'], + $this->get_dml_insert_value_rows( $dml_query ) + ); + if ( null !== $explicit_insert_id ) { + $this->last_insert_id = $explicit_insert_id; + return; + } + + $this->last_insert_id = $this->get_connection_last_insert_id(); + } + + /** + * Read the backend connection's last insert ID. + * + * @return int|string Last insert ID, or 0 when unavailable. + */ + private function get_connection_last_insert_id() { + try { + $insert_id = $this->connection->get_last_insert_id(); + } catch ( Throwable $e ) { + return 0; + } + + return is_numeric( $insert_id ) ? (int) $insert_id : $insert_id; + } + + /** + * Get the MySQL AUTO_INCREMENT column from DML metadata. + * + * @param array $metadata_lookup Column metadata lookup. + * @return string|null AUTO_INCREMENT column name, or null when absent. + */ + private function get_mysql_auto_increment_column_from_metadata( array $metadata_lookup ): ?string { + foreach ( $metadata_lookup as $column_metadata ) { + if ( ! $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + continue; + } + + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' !== $column_name ) { + return $column_name; + } + } + + return null; + } + + /** + * Get DML value rows from translated insert metadata. + * + * @param array $dml_query Translated DML query metadata. + * @return array[] DML value rows. + */ + private function get_dml_insert_value_rows( array $dml_query ): array { + if ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + return $dml_query['value_rows']; + } + + if ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + return array( $dml_query['values'] ); + } + + return array(); + } + + /** + * Get an explicitly supplied AUTO_INCREMENT insert ID from DML values. + * + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @param string[] $columns DML column names. + * @param array[] $value_rows DML value rows. + * @return int|string|null Explicit insert ID, or null when not supplied. + */ + private function get_explicit_mysql_auto_increment_insert_id( string $auto_increment_column, array $columns, array $value_rows ) { + $auto_increment_index = null; + foreach ( $columns as $index => $column ) { + if ( strtolower( (string) $column ) === strtolower( $auto_increment_column ) ) { + $auto_increment_index = $index; + break; + } + } + + if ( null === $auto_increment_index ) { + return null; + } + + foreach ( $value_rows as $values ) { + if ( ! is_array( $values ) || ! isset( $values[ $auto_increment_index ] ) ) { + continue; + } + + $insert_id = $this->get_mysql_insert_id_from_value_sql( (string) $values[ $auto_increment_index ] ); + if ( null !== $insert_id ) { + return $insert_id; + } + } + + return null; + } + + /** + * Parse a simple integer SQL value as a MySQL insert ID. + * + * @param string $value_sql Translated SQL value. + * @return int|string|null Insert ID, or null for DEFAULT/NULL/unsupported values. + */ + private function get_mysql_insert_id_from_value_sql( string $value_sql ) { + $value_sql = trim( $value_sql ); + if ( '' === $value_sql || in_array( strtoupper( $value_sql ), array( 'DEFAULT', 'NULL' ), true ) ) { + return null; + } + + if ( + strlen( $value_sql ) >= 2 + && ( + ( "'" === $value_sql[0] && "'" === $value_sql[ strlen( $value_sql ) - 1 ] ) + || ( '"' === $value_sql[0] && '"' === $value_sql[ strlen( $value_sql ) - 1 ] ) + ) + ) { + $value_sql = substr( $value_sql, 1, -1 ); + } + + if ( isset( $value_sql[0] ) && '+' === $value_sql[0] ) { + $value_sql = substr( $value_sql, 1 ); + } + + if ( '' === $value_sql || ! ctype_digit( $value_sql ) ) { + return null; + } + + $value_sql = ltrim( $value_sql, '0' ); + if ( '' === $value_sql ) { + $value_sql = '0'; + } + + return is_numeric( $value_sql ) ? (int) $value_sql : $value_sql; + } + /** * Repair PostgreSQL identity sequences for successful explicit identity writes. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection.php new file mode 100644 index 000000000..3e787a7e6 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection.php @@ -0,0 +1,22 @@ + new PDO( 'sqlite::memory:' ) ) ); + } + + /** + * Return a stale insert ID to verify driver-level MySQL compatibility. + * + * @param string|null $sequence Optional sequence name. + * @return string + */ + public function get_last_insert_id( ?string $sequence = null ): string { + return '29'; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 44b95b422..08403dc49 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php'; require_once __DIR__ . '/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php'; require_once __DIR__ . '/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php'; +require_once __DIR__ . '/WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection.php'; /** * Unit tests for the PostgreSQL driver scaffold. @@ -1059,11 +1060,74 @@ public function test_get_insert_id_casts_numeric_strings(): void { $driver = $this->create_driver(); $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); - $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE t ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO t (`value`) VALUES ('first')" ); $this->assertSame( 1, $driver->get_insert_id() ); } + /** + * Tests explicit MySQL AUTO_INCREMENT values are exposed as the insert ID. + */ + public function test_get_insert_id_uses_explicit_mysql_auto_increment_value(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY AUTOINCREMENT, + post_title TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (587, 'explicit')" ); + + $this->assertSame( 587, $driver->get_insert_id() ); + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_posts WHERE ID = 587' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'explicit', $rows[0]->post_title ); + } + + /** + * Tests inserts into tables without AUTO_INCREMENT do not expose stale IDs. + */ + public function test_get_insert_id_is_zero_for_non_auto_increment_insert(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $driver->query( + 'CREATE TABLE wptests_term_relationships ( + object_id INTEGER NOT NULL DEFAULT 0, + term_taxonomy_id INTEGER NOT NULL DEFAULT 0, + term_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (object_id, term_taxonomy_id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_term_relationships ( + object_id bigint(20) unsigned NOT NULL DEFAULT 0, + term_taxonomy_id bigint(20) unsigned NOT NULL DEFAULT 0, + term_order int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (object_id, term_taxonomy_id) + )' + ); + + $driver->query( 'INSERT INTO wptests_term_relationships (`object_id`, `term_taxonomy_id`, `term_order`) VALUES (587, 1, 0)' ); + + $this->assertSame( 0, $driver->get_insert_id() ); + } + /** * Tests transaction methods delegate to PDO. */ @@ -5314,6 +5378,16 @@ private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Dri return new WP_PostgreSQL_Driver( $connection, $db_name ); } + /** + * Creates a SQLite-backed driver whose connection reports a stale insert ID. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_stale_connection_insert_id(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection(); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + /** * Creates a SQLite-backed driver that uses PostgreSQL quote translation. * From dac9cbfd42672f77b8f236cab0bbab72aa5cd3d5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 08:25:34 +0000 Subject: [PATCH 088/142] Preserve insert ID for upsert conflicts --- .../postgresql/class-wp-postgresql-driver.php | 47 +++++++++++++------ .../tests/WP_PostgreSQL_Driver_Tests.php | 20 ++++++++ 2 files changed, 52 insertions(+), 15 deletions(-) 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 index 2ef534c40..d300941c1 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3463,8 +3463,8 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): } return array( - 'action' => 'upsert', - 'sql' => sprintf( + 'action' => 'upsert', + 'sql' => sprintf( 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', $this->connection->quote_identifier( $table_name ), implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), @@ -3472,12 +3472,13 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $conflict_columns ) ), implode( ', ', $assignments ) ), - 'table_name' => $table_name, - 'columns' => $columns, - 'values' => $inserted_value_rows[0] ?? array(), - 'value_rows' => $inserted_value_rows, - 'conflict_columns' => $conflict_columns, - 'inserted_new_row' => count( $inserted_value_rows ) > 0, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $inserted_value_rows[0] ?? array(), + 'value_rows' => $inserted_value_rows, + 'insert_id_value_rows' => $value_rows, + 'conflict_columns' => $conflict_columns, + 'inserted_new_row' => count( $inserted_value_rows ) > 0, ); } @@ -4056,22 +4057,19 @@ private function set_last_insert_id_after_dml_success( array $dml_query, int $af return; } - if ( isset( $dml_query['inserted_new_row'] ) && ! $dml_query['inserted_new_row'] ) { - $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 = $this->get_connection_last_insert_id(); + $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 = $this->get_connection_last_insert_id(); + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; return; } @@ -4084,13 +4082,18 @@ private function set_last_insert_id_after_dml_success( array $dml_query, int $af $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( $auto_increment_column, $dml_query['columns'], - $this->get_dml_insert_value_rows( $dml_query ) + $this->get_dml_insert_id_value_rows( $dml_query ) ); if ( null !== $explicit_insert_id ) { $this->last_insert_id = $explicit_insert_id; return; } + if ( ! $inserted_new_row ) { + $this->last_insert_id = 0; + return; + } + $this->last_insert_id = $this->get_connection_last_insert_id(); } @@ -4148,6 +4151,20 @@ private function get_dml_insert_value_rows( array $dml_query ): array { return array(); } + /** + * Get DML value rows used for MySQL insert ID detection. + * + * @param array $dml_query Translated DML query metadata. + * @return array[] DML value rows. + */ + private function get_dml_insert_id_value_rows( array $dml_query ): array { + if ( isset( $dml_query['insert_id_value_rows'] ) && is_array( $dml_query['insert_id_value_rows'] ) ) { + return $dml_query['insert_id_value_rows']; + } + + return $this->get_dml_insert_value_rows( $dml_query ); + } + /** * Get an explicitly supplied AUTO_INCREMENT insert ID from DML values. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 08403dc49..120ba1d96 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1100,6 +1100,26 @@ public function test_get_insert_id_uses_explicit_mysql_auto_increment_value(): v $this->assertSame( 'explicit', $rows[0]->post_title ); } + /** + * Tests upsert conflict updates expose explicit AUTO_INCREMENT values as the insert ID. + */ + public function test_get_insert_id_returns_explicit_auto_increment_value_for_upsert_conflict_update(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (7, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT value FROM wptests_identity_upsert WHERE id = 7' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'updated', $rows[0]->value ); + } + /** * Tests inserts into tables without AUTO_INCREMENT do not expose stale IDs. */ From 2d836341a6c5cb40994ba7f7b9f3392ab7d7106a Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 12:29:51 +0000 Subject: [PATCH 089/142] Bound PostgreSQL CI checks --- .github/workflows/end-to-end-tests.yml | 1 + .github/workflows/phpunit-tests-run.yml | 7 +- .github/workflows/phpunit-tests.yml | 12 +++ .github/workflows/wp-tests-end-to-end.yml | 1 + .github/workflows/wp-tests-phpunit-run.js | 29 +++++- .github/workflows/wp-tests-phpunit.yml | 9 +- packages/mysql-on-sqlite/phpunit.xml.dist | 16 +++- ...-wp-postgresql-create-table-translator.php | 18 ++-- ...onnection_Statement_Savepoint_Fake_PDO.php | 91 +++++++++++++++++++ .../tests/WP_PostgreSQL_Connection_Tests.php | 91 +------------------ 10 files changed, 169 insertions(+), 106 deletions(-) create mode 100644 packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 7be965a7c..f6451509a 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -13,6 +13,7 @@ permissions: {} jobs: test: name: End-to-end Tests + if: github.event_name != 'pull_request' || github.head_ref != 'codex/postgresql-backend' runs-on: ubuntu-latest timeout-minutes: 20 permissions: diff --git a/.github/workflows/phpunit-tests-run.yml b/.github/workflows/phpunit-tests-run.yml index 2eec8ee2f..bbc4a26a8 100644 --- a/.github/workflows/phpunit-tests-run.yml +++ b/.github/workflows/phpunit-tests-run.yml @@ -17,6 +17,11 @@ on: required: false type: 'string' default: 'latest' + testsuite: + description: 'PHPUnit test suite to run.' + required: false + type: 'string' + default: 'default' env: LOCAL_PHP: ${{ inputs.php }}-fpm @@ -109,5 +114,5 @@ jobs: composer-options: "--optimize-autoloader" - name: Run PHPUnit tests - run: php ./vendor/bin/phpunit -c ./phpunit.xml.dist + run: php ./vendor/bin/phpunit -c ./phpunit.xml.dist --testsuite '${{ inputs.testsuite }}' working-directory: packages/mysql-on-sqlite diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 232930877..d9ac83c1f 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -47,3 +47,15 @@ jobs: os: ${{ matrix.os }} php: ${{ matrix.php }} sqlite: ${{ matrix.sqlite || 'latest' }} + + postgresql-test: + name: PHP 8.3 / PostgreSQL package tests + uses: ./.github/workflows/phpunit-tests-run.yml + permissions: + contents: read # Required to clone the repo. + secrets: inherit + with: + os: ubuntu-latest + php: '8.3' + sqlite: latest + testsuite: postgresql diff --git a/.github/workflows/wp-tests-end-to-end.yml b/.github/workflows/wp-tests-end-to-end.yml index 7b3637e4f..48a0a57a4 100644 --- a/.github/workflows/wp-tests-end-to-end.yml +++ b/.github/workflows/wp-tests-end-to-end.yml @@ -13,6 +13,7 @@ permissions: {} jobs: test: name: WordPress End-to-end Tests + if: github.event_name != 'pull_request' || github.head_ref != 'codex/postgresql-backend' runs-on: ubuntu-latest timeout-minutes: 20 permissions: diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index fccfc4fa4..e977ac950 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -5,13 +5,14 @@ * 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 sqliteExpectedErrors = [ 'Tests_DB_Charset::test_invalid_characters_in_query', @@ -111,6 +112,9 @@ console.log( `Running WordPress PHPUnit tests with ${ backend } expected-result if ( requiresNativeParserExtension ) { console.log( 'Native parser extension is required for this PHPUnit run.' ); } +if ( phpunitArgs.length > 0 ) { + console.log( 'PHPUnit arguments:', phpunitArgs ); +} console.log( 'Expected errors:', expectedByBackend[ backend ].errors ); console.log( 'Expected failures:', expectedByBackend[ backend ].failures ); @@ -132,8 +136,16 @@ try { let phpunitCommandError = null; try { - execSync( - 'composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose', + execFileSync( + 'composer', + [ + 'run', + 'wp-test-php', + '--', + '--log-junit=phpunit-results.xml', + '--verbose', + ...phpunitArgs, + ], { stdio: 'inherit' } ); console.log( '\nAll tests passed, checking if expected errors/failures occurred...' ); @@ -224,6 +236,16 @@ function normalizeBackend( value ) { throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ value }` ); } +function getPhpUnitArgs() { + const args = []; + + if ( process.env.WP_TEST_PHPUNIT_FILTER ) { + args.push( '--filter', process.env.WP_TEST_PHPUNIT_FILTER ); + } + + return args; +} + function verifyNativeParserExtension() { const verifier = path.join( repositoryRoot, 'wordpress', 'native-verify-extension.php' ); if ( ! fs.existsSync( verifier ) ) { @@ -630,6 +652,7 @@ function summarizeTestcases( testcases ) { function emptySummary() { return { backend, + filter: process.env.WP_TEST_PHPUNIT_FILTER || '', total: 0, passed: 0, errors: 0, diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index d74cd0f6a..460850568 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -74,6 +74,7 @@ jobs: - name: Run WordPress PHPUnit tests env: WP_TEST_DB_BACKEND: postgresql + WP_TEST_PHPUNIT_FILTER: '^Tests_DB::' run: node .github/workflows/wp-tests-phpunit-run.js - name: Upload PHPUnit count @@ -173,11 +174,13 @@ jobs: const sqlite = readResult( 'sqlite' ); const postgresql = readResult( 'postgresql' ); - const progress = renderProgressBar( postgresql.passed, sqlite.passed ); + 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; PostgreSQL ${ formatNumber( postgresql.passed ) } passed`, - `\`${ progress }\``, + `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' ); diff --git a/packages/mysql-on-sqlite/phpunit.xml.dist b/packages/mysql-on-sqlite/phpunit.xml.dist index a41280c83..ffd0b6781 100644 --- a/packages/mysql-on-sqlite/phpunit.xml.dist +++ b/packages/mysql-on-sqlite/phpunit.xml.dist @@ -15,10 +15,24 @@ - + tests/ tests/tools + tests/WP_PostgreSQL_Connection_Tests.php + tests/WP_PostgreSQL_Create_Table_Translator_Tests.php + tests/WP_PostgreSQL_DB_Tests.php + tests/WP_PostgreSQL_Driver_RegExp_Tests.php + tests/WP_PostgreSQL_Driver_Tests.php + tests/WP_PostgreSQL_Install_Functions_Tests.php + + + tests/WP_PostgreSQL_Connection_Tests.php + tests/WP_PostgreSQL_Create_Table_Translator_Tests.php + tests/WP_PostgreSQL_DB_Tests.php + tests/WP_PostgreSQL_Driver_RegExp_Tests.php + tests/WP_PostgreSQL_Driver_Tests.php + tests/WP_PostgreSQL_Install_Functions_Tests.php diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php index 6af101cca..8c6bc24cf 100644 --- 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 @@ -409,13 +409,15 @@ private function get_identifier_value( ?WP_Parser_Node $node ): string { * @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 ); - list ( $table_charset, $table_collation ) = $this->get_table_charset_and_collation( $create_table ); - $columns = array(); - $column_types = array(); - $indexes = array(); - $ordinal = 1; - $index_ordinal = 1; + $table_name = $this->get_table_name( $create_table ); + $charset = $this->get_table_charset_and_collation( $create_table ); + $columns = array(); + $column_types = array(); + $indexes = array(); + $ordinal = 1; + $index_ordinal = 1; + + list ( $table_charset, $table_collation ) = $charset; $element_list = $create_table->get_first_child_node( 'tableElementList' ); if ( ! $element_list ) { @@ -454,7 +456,7 @@ private function extract_create_table_metadata( WP_Parser_Node $create_table, bo $column_metadata['extra'] = $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ? 'auto_increment' : ''; } - $columns[] = $column_metadata; + $columns[] = $column_metadata; $column_types[ strtolower( $name ) ] = $column_type; ++$ordinal; continue; diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php new file mode 100644 index 000000000..55170059d --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php @@ -0,0 +1,91 @@ +pdo = new PDO( 'sqlite::memory:' ); + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Begin a transaction. + * + * @return bool Whether the transaction started. + */ + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->beginTransaction(); + } + + /** + * Roll back the active transaction. + * + * @return bool Whether the transaction was rolled back. + */ + public function rollBack(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->rollBack(); + } + + /** + * Check whether a transaction is active. + * + * @return bool Whether a transaction is active. + */ + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->inTransaction(); + } + + /** + * Prepare a SQL statement. + * + * @param string $sql SQL statement. + * @return PDOStatement Statement object. + */ + public function prepare( string $sql ): PDOStatement { + 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 index 4782d56fd..14a3a5d26 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -3,6 +3,7 @@ use PHPUnit\Framework\TestCase; require_once __DIR__ . '/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php'; +require_once __DIR__ . '/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php'; /** * Unit tests for the PostgreSQL connection scaffold. @@ -320,93 +321,3 @@ private function create_connection_with_pdo_fixture( $pdo_fixture ): WP_PostgreS return $connection; } } - -/** - * PDO-like fixture that records statement savepoint commands. - */ -class WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO { - /** - * Recorded exec() SQL. - * - * @var string[] - */ - public $exec_sql = array(); - - /** - * SQLite PDO used for real statement execution. - * - * @var PDO - */ - private $pdo; - - /** - * Constructor. - */ - public function __construct() { - $this->pdo = new PDO( 'sqlite::memory:' ); - $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); - $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); - } - - /** - * Begin a transaction. - * - * @return bool Whether the transaction started. - */ - public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - return $this->pdo->beginTransaction(); - } - - /** - * Roll back the active transaction. - * - * @return bool Whether the transaction was rolled back. - */ - public function rollBack(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - return $this->pdo->rollBack(); - } - - /** - * Check whether a transaction is active. - * - * @return bool Whether a transaction is active. - */ - public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - return $this->pdo->inTransaction(); - } - - /** - * Prepare a SQL statement. - * - * @param string $sql SQL statement. - * @return PDOStatement Statement object. - */ - public function prepare( string $sql ): PDOStatement { - 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 ); - } -} From c7093b85616f0c8bec201be8083629850d503d97 Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 21:26:03 +0000 Subject: [PATCH 090/142] Stabilize PostgreSQL savepoint test --- ...PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php | 8 ++++++++ .../tests/WP_PostgreSQL_Connection_Tests.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php index 55170059d..27dcecb8d 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php @@ -11,6 +11,13 @@ class WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO { */ public $exec_sql = array(); + /** + * Recorded prepare() SQL. + * + * @var string[] + */ + public $prepared_sql = array(); + /** * SQLite PDO used for real statement execution. * @@ -61,6 +68,7 @@ public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventi * @return PDOStatement Statement object. */ public function prepare( string $sql ): PDOStatement { + $this->prepared_sql[] = $sql; return $this->pdo->prepare( $sql ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index 14a3a5d26..7f1b31727 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -231,7 +231,7 @@ public function test_query_does_not_wrap_transaction_control_statement_in_savepo $pdo->beginTransaction(); $connection->query( 'ROLLBACK;' ); - $this->assertFalse( $pdo->inTransaction() ); + $this->assertSame( array( 'ROLLBACK;' ), $pdo->prepared_sql ); $this->assertSame( array(), $pdo->exec_sql ); } From 2ac46d7bb77994cb0ebc7e06b16cc77502db069c Mon Sep 17 00:00:00 2001 From: adamziel Date: Fri, 12 Jun 2026 22:24:46 +0000 Subject: [PATCH 091/142] Run full PostgreSQL WordPress PHPUnit CI --- .github/workflows/wp-tests-phpunit.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 460850568..6b3dd813d 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -54,7 +54,7 @@ jobs: postgresql-test: name: WordPress PHPUnit Tests / PostgreSQL runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 continue-on-error: true permissions: contents: read # Required to clone the repo. @@ -74,7 +74,6 @@ jobs: - name: Run WordPress PHPUnit tests env: WP_TEST_DB_BACKEND: postgresql - WP_TEST_PHPUNIT_FILTER: '^Tests_DB::' run: node .github/workflows/wp-tests-phpunit-run.js - name: Upload PHPUnit count From 144f115fa0a3adda1a4a0cb453ca684a799da9f1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sat, 13 Jun 2026 00:52:15 +0000 Subject: [PATCH 092/142] Cache PostgreSQL metadata lookups --- .../postgresql/class-wp-postgresql-driver.php | 122 +++- .../tests/WP_PostgreSQL_DB_Tests.php | 588 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 59 ++ .../postgresql/class-wp-postgresql-db.php | 93 ++- 4 files changed, 829 insertions(+), 33 deletions(-) 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 index d300941c1..f10574d72 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -78,6 +78,27 @@ class WP_PostgreSQL_Driver { */ private $last_postgresql_queries = array(); + /** + * Whether the MySQL metadata side tables were ensured for this connection. + * + * @var bool + */ + private $mysql_schema_metadata_tables_ensured = false; + + /** + * Resolved backend schema names for MySQL table introspection. + * + * @var array + */ + private $mysql_table_schema_introspection_cache = array(); + + /** + * Ordered DML column metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_dml_column_metadata_cache = array(); + /** * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. * @@ -337,6 +358,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $drop_query['temporary'] ); $result = $this->execute_postgresql_statements( $drop_query['statements'] ); + $this->maybe_clear_mysql_schema_metadata_table_state( $drop_query['tables'] ); $this->delete_mysql_schema_metadata_for_table_targets( $metadata_targets ); return $result; } @@ -742,6 +764,10 @@ private function apply_mysql_set_names_query( string $query ): bool { * Create the MySQL schema metadata tables used by dbDelta emulation. */ private function ensure_mysql_schema_metadata_tables(): void { + if ( $this->mysql_schema_metadata_tables_ensured ) { + return; + } + $this->connection->query( sprintf( 'CREATE TABLE IF NOT EXISTS %s ( @@ -782,6 +808,77 @@ private function ensure_mysql_schema_metadata_tables(): void { $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) ) ); + + $this->mysql_schema_metadata_tables_ensured = true; + } + + /** + * Clear all cached MySQL metadata derived from side tables. + */ + private function clear_mysql_metadata_caches(): void { + $this->mysql_table_schema_introspection_cache = array(); + $this->mysql_dml_column_metadata_cache = array(); + } + + /** + * Clear cached MySQL metadata for one table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + */ + private function clear_mysql_metadata_cache_for_table( string $table_schema, string $table_name ): void { + unset( $this->mysql_dml_column_metadata_cache[ $this->get_mysql_metadata_cache_key( $table_schema, $table_name ) ] ); + + /* + * Temporary table creation/drop can change which backend schema an + * unqualified MySQL table resolves to, so clear all schema resolutions. + */ + $this->mysql_table_schema_introspection_cache = array(); + } + + /** + * Get a cache key for metadata keyed by backend schema and table name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return string Cache key. + */ + private function get_mysql_metadata_cache_key( string $table_schema, string $table_name ): string { + return $table_schema . "\0" . $table_name; + } + + /** + * Reset metadata side-table state if a query drops the side tables directly. + * + * @param string[] $table_names Dropped table names. + */ + private function maybe_clear_mysql_schema_metadata_table_state( array $table_names ): void { + foreach ( $table_names as $table_name ) { + if ( ! $this->is_mysql_schema_metadata_table_name( (string) $table_name ) ) { + continue; + } + + $this->mysql_schema_metadata_tables_ensured = false; + $this->clear_mysql_metadata_caches(); + return; + } + } + + /** + * Check whether a table name belongs to the driver's metadata side tables. + * + * @param string $table_name Table name. + * @return bool Whether this is a metadata side table. + */ + private function is_mysql_schema_metadata_table_name( string $table_name ): bool { + return in_array( + $table_name, + array( + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + ), + true + ); } /** @@ -886,6 +983,7 @@ private function store_mysql_schema_metadata_for_schema( string $query, $table_s $table_name = $metadata['table_name']; $this->delete_mysql_schema_metadata_for_tables( array( $table_name ), $schema_name ); + $this->clear_mysql_metadata_cache_for_table( $schema_name, $table_name ); $column_nullable = array(); foreach ( $metadata['columns'] as $column ) { @@ -943,6 +1041,7 @@ private function delete_mysql_schema_metadata_for_tables( array $table_names, st ), $params ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } } @@ -1015,6 +1114,7 @@ private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { ), array( $metadata['default'], $table_schema, $table_name, $metadata['column'] ) ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } } @@ -1047,6 +1147,7 @@ private function insert_mysql_column_metadata( string $table_schema, string $tab $column['extra'] ?? '', ) ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } /** @@ -1064,6 +1165,7 @@ private function delete_mysql_column_metadata( string $table_schema, string $tab ), array( $table_schema, $table_name, $column_name ) ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } /** @@ -2578,8 +2680,16 @@ private function resolve_mysql_table_schema_for_introspection( string $schema_na return $schema_name; } + $cache_key = $this->get_mysql_metadata_cache_key( $schema_name, $table_name ); + if ( isset( $this->mysql_table_schema_introspection_cache[ $cache_key ] ) ) { + return $this->mysql_table_schema_introspection_cache[ $cache_key ]; + } + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); - return null === $temporary_schema ? $schema_name : $temporary_schema; + $resolved_schema = null === $temporary_schema ? $schema_name : $temporary_schema; + + $this->mysql_table_schema_introspection_cache[ $cache_key ] = $resolved_schema; + return $resolved_schema; } /** @@ -4999,7 +5109,12 @@ private function get_mysql_dml_column_metadata( string $table_name ): array { $this->ensure_mysql_schema_metadata_tables(); $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); - $stmt = $this->connection->query( + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_dml_column_metadata_cache ) ) { + return $this->mysql_dml_column_metadata_cache[ $cache_key ]; + } + + $stmt = $this->connection->query( sprintf( 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, extra FROM %s @@ -5010,7 +5125,8 @@ private function get_mysql_dml_column_metadata( string $table_name ): array { array( $table_schema, $table_name ) ); - return $stmt->fetchAll( PDO::FETCH_ASSOC ); + $this->mysql_dml_column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return $this->mysql_dml_column_metadata_cache[ $cache_key ]; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 90861dab9..6e1bd0c04 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -607,6 +607,191 @@ public function get_connection(): WP_PostgreSQL_Connection { ); } + /** + * Tests broad DDL cache invalidation preserves temporary table charset metadata. + */ + public function test_broad_cache_invalidation_preserves_temporary_table_charset_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = true; + public $charset = 'utf8'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + array( + 'column_name' => 'b', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return true; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + 'CREATE TEMPORARY TABLE wptests_temp_declared_charset ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET koi8r )' +); + +$before_a = $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ); +$before_b = $db->get_col_charset( 'wptests_temp_declared_charset', 'b' ); +$altered = $db->query( 'ALTER TABLE wptests_unrelated ADD COLUMN flag INTEGER' ); +$after_a = $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ); +$after_b = $db->get_col_charset( 'wptests_temp_declared_charset', 'b' ); + +wp_postgresql_db_test_respond( + array( + 'before_a' => $before_a, + 'before_b' => $before_b, + 'altered' => $altered, + 'after_a' => $after_a, + 'after_b' => $after_b, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( 'big5', $result['before_a'] ); + $this->assertSame( 'koi8r', $result['before_b'] ); + $this->assertTrue( $result['altered'] ); + $this->assertSame( 'big5', $result['after_a'] ); + $this->assertSame( 'koi8r', $result['after_b'] ); + $this->assertSame( + array( + 'temp_schema:wptests_temp_declared_charset', + 'temp_schema:wptests_temp_declared_charset', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'ALTER TABLE wptests_unrelated ADD COLUMN flag INTEGER', + ), + $result['driver_queries'] + ); + } + /** * Tests charset lookups can use MySQL metadata stored by the PostgreSQL driver. */ @@ -1028,17 +1213,13 @@ public function get_queries(): array { 'temp_schema:wptests_temp_comments', 'temp_schema:wptests_comments', 'metadata_exists', - 'temp_schema:wptests_comments', - 'metadata_exists', 'temp_schema:wptests_native_text', - 'metadata_exists', 'native_columns:wptests_native_text', ), $result['connection_queries'] ); $this->assertSame( array( - 'SHOW FULL COLUMNS FROM `wptests_comments`', 'SHOW FULL COLUMNS FROM `wptests_comments`', 'SHOW FULL COLUMNS FROM `wptests_native_text`', ), @@ -1046,6 +1227,405 @@ public function get_queries(): array { ); } + /** + * Tests PostgreSQL column metadata is cached per table and can be invalidated. + */ + public function test_column_charset_metadata_cache_reuses_table_load_until_invalidated(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $metadata_length = 50; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 1, + ), + ) + ); + } + + if ( false !== strpos( $sql, WP_PostgreSQL_DB::MYSQL_CHARSET_METADATA_TABLE ) ) { + $this->queries[] = 'stored:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( + array( + array( + 'column_name' => 'name', + 'column_type' => 'varchar(' . $this->metadata_length . ')', + 'collation_name' => 'utf8mb4_unicode_ci', + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_metadata_length( int $metadata_length ): void { + $this->metadata_length = $metadata_length; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Cached_Metadata_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Cached_Metadata_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$first = $db->get_col_length( 'wptests_cache_probe', 'name' ); + +$connection->set_metadata_length( 75 ); +$second = $db->get_col_length( 'WPTESTS_CACHE_PROBE', 'NAME' ); + +$clear_cache = new ReflectionMethod( WP_PostgreSQL_DB::class, 'clear_postgresql_table_charset_cache' ); +$clear_cache->setAccessible( true ); +$clear_cache->invoke( $db, array( 'wptests_cache_probe' ) ); + +$third = $db->get_col_length( 'wptests_cache_probe', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'first' => $first, + 'second' => $second, + 'third' => $third, + 'queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['first'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['second'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 75, + ), + $result['third'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_cache_probe', + 'metadata_exists', + 'stored:wptests_cache_probe', + 'temp_schema:wptests_cache_probe', + 'stored:wptests_cache_probe', + ), + $result['queries'] + ); + } + + /** + * Tests plain permanent CREATE TABLE invalidates cached missing metadata. + */ + public function test_plain_create_table_invalidates_cached_missing_column_charset_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function __( $text ) { + return $text; +} + +class WP_Error { + public $code; + public $message; + + public function __construct( $code = '', $message = '' ) { + $this->code = $code; + $this->message = $message; + } +} + +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} + +class wpdb { + public $ready = true; + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $plain_table_exists = false; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $table = $params[0] ?? ''; + $this->queries[] = 'native_columns:' . $table; + + if ( $this->plain_table_exists && 'wptests_plain_metadata_cache' === $table ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'name', + 'data_type' => 'character varying', + 'character_maximum_length' => 191, + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_plain_table_exists(): void { + $this->plain_table_exists = true; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 0 === stripos( $query, 'CREATE TABLE' ) ) { + $this->fake_connection->set_plain_table_exists(); + return true; + } + + return array(); + } + + public function get_last_return_value() { + return 0; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$missing = $db->get_col_charset( 'wptests_plain_metadata_cache', 'name' ); +$created = $db->query( + 'CREATE TABLE wptests_plain_metadata_cache ( + id INTEGER NOT NULL, + name VARCHAR(191) NOT NULL + )' +); +$reloaded = $db->get_col_charset( 'wptests_plain_metadata_cache', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'missing_is_error' => $missing instanceof WP_Error, + 'created' => $created, + 'reloaded' => $reloaded, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['missing_is_error'] ); + $this->assertTrue( $result['created'] ); + $this->assertSame( 'utf8mb4', $result['reloaded'] ); + $this->assertSame( + array( + 'temp_schema:wptests_plain_metadata_cache', + 'metadata_exists', + 'native_columns:wptests_plain_metadata_cache', + 'temp_schema:wptests_plain_metadata_cache', + 'native_columns:wptests_plain_metadata_cache', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_plain_metadata_cache`', + "CREATE TABLE wptests_plain_metadata_cache (\n\t\tid INTEGER NOT NULL,\n\t\tname VARCHAR(191) NOT NULL\n\t)", + 'SHOW FULL COLUMNS FROM `wptests_plain_metadata_cache`', + ), + $result['driver_queries'] + ); + } + /** * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 120ba1d96..ae8253cd6 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -209,6 +209,65 @@ public function test_non_strict_insert_appends_omitted_not_null_defaults_from_my $this->assertSame( '0', $posts[0]->post_parent ); } + /** + * Tests DML column metadata is cached and invalidated after metadata changes. + */ + public function test_dml_column_metadata_cache_reuses_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( + 'CREATE TABLE wptests_cache_dml ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_cache_dml ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + label varchar(20) NOT NULL DEFAULT '', + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $metadata_select_count = 0; + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$metadata_select_count ): void { + if ( + false !== strpos( $sql, 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, extra' ) + && false !== strpos( $sql, WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) + ) { + ++$metadata_select_count; + } + } + ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (1)' ) ); + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (2)' ) ); + $this->assertSame( 1, $metadata_select_count ); + + $driver->query( "ALTER TABLE wptests_cache_dml ALTER COLUMN status SET DEFAULT 'published'" ); + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (3)' ) ); + $this->assertSame( 2, $metadata_select_count ); + + $rows = $driver->query( 'SELECT id, label, status FROM wptests_cache_dml ORDER BY id' ); + $this->assertSame( + array( + array( '1', '', 'draft' ), + array( '2', '', 'draft' ), + array( '3', '', 'published' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->label, $row->status ); + }, + $rows + ) + ); + } + /** * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. */ diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index cedd04363..52229f10e 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -40,6 +40,20 @@ class WP_PostgreSQL_DB extends wpdb { */ private $postgresql_temporary_charset_metadata = array(); + /** + * Request-local MySQL charset metadata keyed by normalized table name. + * + * @var array + */ + private $postgresql_column_charset_metadata_cache = array(); + + /** + * Cached existence state for the PostgreSQL MySQL charset metadata table. + * + * @var bool|null + */ + private $postgresql_charset_metadata_table_exists = null; + /** * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. * @@ -247,9 +261,12 @@ public function get_col_length( $table, $column ) { $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 = $this->get_postgresql_column_charset_metadata( $table ); + $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 @@ -537,23 +554,16 @@ private function store_postgresql_create_table_charset_metadata( string $query ) 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 ) ) { - if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { - $table_name = $this->get_postgresql_create_table_name( $query ); - if ( null !== $table_name ) { - $this->clear_postgresql_table_charset_cache( array( $table_name ) ); - } - } return; } if ( ! $this->is_postgresql_mysql_charset_metadata_create_query( $query ) ) { - if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { - $table_name = $this->get_postgresql_create_table_name( $query ); - if ( null !== $table_name ) { - $this->clear_postgresql_table_charset_cache( array( $table_name ) ); - } - } return; } @@ -640,8 +650,7 @@ private function store_postgresql_create_table_charset_metadata( string $query ) ); } - $tablekey = $this->get_postgresql_metadata_key( $table_name ); - unset( $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ] ); + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); } } catch ( Throwable $e ) { return; @@ -663,8 +672,9 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query return; } + $this->clear_postgresql_table_charset_cache( $tables ); + if ( $this->is_postgresql_drop_temporary_table_query( $query ) ) { - $this->clear_postgresql_table_charset_cache( $tables ); return; } @@ -680,9 +690,6 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query AND lower(table_name) = lower(?)', array( $table ) ); - - $tablekey = $this->get_postgresql_metadata_key( $table ); - unset( $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ] ); } } catch ( Throwable $e ) { return; @@ -697,14 +704,29 @@ private function delete_postgresql_dropped_table_charset_metadata( string $query private function clear_postgresql_table_charset_cache( array $tables ): void { foreach ( $tables as $table ) { $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + if ( self::MYSQL_CHARSET_METADATA_TABLE === $this->normalize_postgresql_table_name( (string) $table ) ) { + $this->postgresql_charset_metadata_table_exists = null; + } + unset( $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ], - $this->postgresql_temporary_charset_metadata[ $tablekey ] + $this->postgresql_temporary_charset_metadata[ $tablekey ], + $this->postgresql_column_charset_metadata_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_charset_metadata_table_exists = null; + } + /** * Ensure the PostgreSQL side table for MySQL charset metadata exists. */ @@ -721,6 +743,7 @@ private function ensure_postgresql_charset_metadata_table(): void { PRIMARY KEY (table_schema, table_name, column_name) )' ); + $this->postgresql_charset_metadata_table_exists = true; } /** @@ -830,6 +853,10 @@ private function is_postgresql_drop_temporary_table_query( string $query ): bool * @return bool Whether the metadata table exists in the current schema. */ private function postgresql_charset_metadata_table_exists(): bool { + if ( null !== $this->postgresql_charset_metadata_table_exists ) { + return $this->postgresql_charset_metadata_table_exists; + } + try { $stmt = $this->dbh->get_connection()->query( 'SELECT EXISTS ( @@ -840,10 +867,12 @@ private function postgresql_charset_metadata_table_exists(): bool { )', array( self::MYSQL_CHARSET_METADATA_TABLE ) ); - return (bool) $stmt->fetchColumn(); + $this->postgresql_charset_metadata_table_exists = (bool) $stmt->fetchColumn(); } catch ( Throwable $e ) { - return false; + $this->postgresql_charset_metadata_table_exists = false; } + + return $this->postgresql_charset_metadata_table_exists; } /** @@ -857,31 +886,41 @@ private function get_postgresql_column_charset_metadata( string $table ) { 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 ) { - $tablekey = $this->get_postgresql_metadata_key( $table ); if ( array_key_exists( $tablekey, $this->postgresql_temporary_charset_metadata ) ) { - return $this->postgresql_temporary_charset_metadata[ $tablekey ]; + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->postgresql_temporary_charset_metadata[ $tablekey ]; + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; } - return $this->get_native_postgresql_column_charset_metadata( $table, $temp_schema ); + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->get_native_postgresql_column_charset_metadata( $table, $temp_schema ); + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; } $columns = $this->get_stored_postgresql_column_charset_metadata( $table ); if ( false !== $columns && ! empty( $columns ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $columns; return $columns; } $columns = $this->get_driver_postgresql_column_charset_metadata( $table ); if ( false !== $columns && ! empty( $columns ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $columns; return $columns; } - return $this->get_native_postgresql_column_charset_metadata( $table ); + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->get_native_postgresql_column_charset_metadata( $table ); + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; } /** @@ -1862,6 +1901,8 @@ public function query( $query ) { $this->store_postgresql_create_table_charset_metadata( $query ); } elseif ( 'drop' === $statement_type ) { $this->delete_postgresql_dropped_table_charset_metadata( $query ); + } elseif ( in_array( $statement_type, array( 'alter', 'truncate' ), true ) ) { + $this->clear_all_postgresql_table_charset_cache(); } if ( in_array( $statement_type, array( 'create', 'alter', 'truncate', 'drop' ), true ) ) { From 81ac332e0748aa5a655c145ae050c16f0f0cf743 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sat, 13 Jun 2026 14:10:28 +0000 Subject: [PATCH 093/142] Cache PostgreSQL introspection results --- .../postgresql/class-wp-postgresql-driver.php | 237 ++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 280 ++++++++++++++++++ 2 files changed, 511 insertions(+), 6 deletions(-) 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 index f10574d72..1bd5572c4 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -29,6 +29,11 @@ class WP_PostgreSQL_Driver { */ 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; + /** * PostgreSQL server version string. * @@ -99,6 +104,20 @@ class WP_PostgreSQL_Driver { */ private $mysql_dml_column_metadata_cache = array(); + /** + * Cached MySQL upsert conflict targets keyed by table and inserted columns. + * + * @var array + */ + private $mysql_upsert_conflict_target_cache = array(); + + /** + * Cached MySQL introspection results keyed by query shape. + * + * @var array + */ + private $mysql_introspection_result_cache = array(); + /** * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. * @@ -818,6 +837,8 @@ private function ensure_mysql_schema_metadata_tables(): void { private function clear_mysql_metadata_caches(): void { $this->mysql_table_schema_introspection_cache = array(); $this->mysql_dml_column_metadata_cache = array(); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); } /** @@ -828,6 +849,8 @@ private function clear_mysql_metadata_caches(): void { */ private function clear_mysql_metadata_cache_for_table( string $table_schema, string $table_name ): void { unset( $this->mysql_dml_column_metadata_cache[ $this->get_mysql_metadata_cache_key( $table_schema, $table_name ) ] ); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); /* * Temporary table creation/drop can change which backend schema an @@ -1209,6 +1232,8 @@ private function insert_mysql_index_metadata( ) ); } + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } /** @@ -1226,6 +1251,7 @@ private function delete_mysql_index_metadata( string $table_schema, string $tabl ), array( $table_schema, $table_name, $index_name ) ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } /** @@ -1253,6 +1279,7 @@ private function rename_mysql_index_column_metadata( ), array( $new_column_name, $table_schema, $table_name, $old_column_name ) ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); } /** @@ -2333,9 +2360,19 @@ private function get_show_index_query( string $query ): ?array { private function execute_describe_query( string $table_name, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'describe', + $fetch_mode, + array( $resolved_schema, $table_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + $sql = $this->get_describe_catalog_query(); $params = array( - $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ), + $resolved_schema, $table_name, ); $stmt = $this->connection->query( $sql, $params ); @@ -2347,6 +2384,8 @@ private function execute_describe_query( string $table_name, $fetch_mode, ...$fe $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->store_mysql_introspection_result_in_cache( $cache_key ); + return $this->last_result; } @@ -2364,9 +2403,19 @@ private function execute_describe_query( string $table_name, $fetch_mode, ...$fe private function execute_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_columns', + $fetch_mode, + array( $resolved_schema, $table_name, $is_full, $like, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + $sql = $this->get_show_columns_catalog_query( $is_full ); $params = array( - $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ), + $resolved_schema, $table_name, ); @@ -2387,6 +2436,8 @@ private function execute_show_columns_query( string $schema_name, string $table_ $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->store_mysql_introspection_result_in_cache( $cache_key ); + return $this->last_result; } @@ -2634,9 +2685,19 @@ private function get_default_mysql_collation_for_charset( string $charset ): str private function execute_show_index_query( string $table_name, ?string $key_name, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_index', + $fetch_mode, + array( $resolved_schema, $table_name, $key_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + $sql = $this->get_show_index_catalog_query(); $params = array( - $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ), + $resolved_schema, $table_name, ); @@ -2665,9 +2726,158 @@ private function execute_show_index_query( string $table_name, ?string $key_name $this->last_column_meta = $this->normalize_column_meta( $stmt ); $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->store_mysql_introspection_result_in_cache( $cache_key ); + return $this->last_result; } + /** + * Load a cached MySQL introspection result into the current query state. + * + * @param string|null $cache_key Cache key, or null when this query shape is not cacheable. + * @return bool Whether a cached result was loaded. + */ + private function load_mysql_introspection_result_from_cache( ?string $cache_key ): bool { + if ( null === $cache_key ) { + return false; + } + + if ( ! array_key_exists( $cache_key, $this->mysql_introspection_result_cache ) ) { + return false; + } + + $cached = $this->mysql_introspection_result_cache[ $cache_key ]; + if ( + ! $this->try_copy_mysql_introspection_cache_value( $cached['column_meta'], $column_meta ) + || ! $this->try_copy_mysql_introspection_cache_value( $cached['result'], $result ) + ) { + unset( $this->mysql_introspection_result_cache[ $cache_key ] ); + return false; + } + + $this->last_column_meta = $column_meta; + $this->last_result = $result; + + return true; + } + + /** + * Store the current MySQL introspection result in the request-local cache. + * + * @param string|null $cache_key Cache key, or null when this query shape is not cacheable. + */ + private function store_mysql_introspection_result_in_cache( ?string $cache_key ): void { + if ( null === $cache_key ) { + return; + } + + if ( + ! $this->try_copy_mysql_introspection_cache_value( $this->last_column_meta, $column_meta ) + || ! $this->try_copy_mysql_introspection_cache_value( $this->last_result, $result ) + ) { + return; + } + + $this->mysql_introspection_result_cache[ $cache_key ] = array( + 'column_meta' => $column_meta, + 'result' => $result, + ); + } + + /** + * Get a cache key for a MySQL introspection query shape. + * + * @param string $query_type Query type. + * @param mixed $fetch_mode PDO fetch mode. + * @param array $parts Query shape parts. + * @return string|null Cache key, or null when the query shape is not cacheable. + */ + private function get_mysql_introspection_result_cache_key( string $query_type, $fetch_mode, array $parts ): ?string { + if ( PDO::FETCH_FUNC === ( (int) $fetch_mode & self::PDO_FETCH_STYLE_MASK ) ) { + return null; + } + + if ( ! $this->is_mysql_introspection_cache_key_value_safe( $parts ) ) { + return null; + } + + return $query_type . "\0" . serialize( $parts ); + } + + /** + * Check whether a value can safely participate in an introspection cache key. + * + * @param mixed $value Value to inspect. + * @param int $depth Recursion depth guard. + * @return bool Whether the value can be safely serialized into a cache key. + */ + private function is_mysql_introspection_cache_key_value_safe( $value, int $depth = 0 ): bool { + if ( 20 < $depth ) { + return false; + } + + if ( null === $value || is_scalar( $value ) ) { + return true; + } + + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( $value as $key => $item ) { + if ( ! is_int( $key ) && ! is_string( $key ) ) { + return false; + } + + if ( ! $this->is_mysql_introspection_cache_key_value_safe( $item, $depth + 1 ) ) { + return false; + } + } + + return true; + } + + /** + * Copy cached introspection data before exposing it to callers. + * + * @param mixed $value Cached value. + * @param mixed $copy Copied value. + * @param int $depth Recursion depth guard. + * @return bool Whether the value could be copied safely. + */ + private function try_copy_mysql_introspection_cache_value( $value, &$copy, int $depth = 0 ): bool { + if ( 20 < $depth ) { + return false; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + if ( ! $this->try_copy_mysql_introspection_cache_value( $item, $item_copy, $depth + 1 ) ) { + return false; + } + $copy[ $key ] = $item_copy; + } + return true; + } + + if ( is_object( $value ) ) { + if ( 'stdClass' !== get_class( $value ) ) { + return false; + } + + $copy = clone $value; + return true; + } + + if ( is_resource( $value ) ) { + return false; + } + + $copy = $value; + return true; + } + /** * Resolve the backend schema for an unqualified MySQL table introspection query. * @@ -3731,14 +3941,25 @@ private function is_supported_mysql_upsert_conflict_probe_token_sequence( array */ private function get_mysql_upsert_conflict_target_columns( string $table_name, array $columns ): ?array { $insert_column_lookup = array(); + $insert_columns = array(); foreach ( $columns as $column ) { - $insert_column_lookup[ strtolower( $column ) ] = true; + $insert_column = strtolower( $column ); + + $insert_column_lookup[ $insert_column ] = true; + $insert_columns[] = $insert_column; } + sort( $insert_columns, SORT_STRING ); $this->ensure_mysql_schema_metadata_tables(); $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); - $stmt = $this->connection->query( + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ) . "\0" . serialize( $insert_columns ); + if ( array_key_exists( $cache_key, $this->mysql_upsert_conflict_target_cache ) ) { + $cached = $this->mysql_upsert_conflict_target_cache[ $cache_key ]; + return null === $cached ? null : array_values( $cached ); + } + + $stmt = $this->connection->query( sprintf( 'SELECT key_name, column_name, sub_part FROM %s @@ -3790,13 +4011,17 @@ private function get_mysql_upsert_conflict_target_columns( string $table_name, a } if ( $index['has_sub_part'] ) { + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = null; return null; } $candidates[] = $index['columns']; } - return 1 === count( $candidates ) ? $candidates[0] : null; + $conflict_columns = 1 === count( $candidates ) ? $candidates[0] : null; + + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = $conflict_columns; + return null === $conflict_columns ? null : array_values( $conflict_columns ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index ae8253cd6..9272f1166 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -11,6 +11,13 @@ * Unit tests for the PostgreSQL driver scaffold. */ class WP_PostgreSQL_Driver_Tests extends TestCase { + /** + * Number of times the static FETCH_FUNC regression callback was invoked. + * + * @var int + */ + private static $mysql_introspection_fetch_func_invocations = 0; + /** * Tests SELECT queries return fetched rows and normalized metadata. */ @@ -1246,6 +1253,15 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $this->install_options_table_with_mysql_metadata( $driver ); + $unique_index_metadata_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$unique_index_metadata_queries ): void { + if ( false !== strpos( $sql, "non_unique = '0'" ) ) { + ++$unique_index_metadata_queries; + } + } + ); + $insert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) VALUES ('siteurl', 'http://example.org', 'yes') ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), @@ -1264,6 +1280,8 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $driver->get_last_postgresql_queries() ); + $this->assertSame( 1, $unique_index_metadata_queries ); + $update = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) VALUES ('siteurl', 'http://example.net', 'no') ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), @@ -1281,11 +1299,32 @@ public function test_wordpress_options_upsert_is_translated_to_postgresql_on_con $driver->get_last_postgresql_queries() ); + $this->assertSame( 1, $unique_index_metadata_queries ); + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); $this->assertCount( 1, $rows ); $this->assertSame( 'http://example.net', $rows[0]->option_value ); $this->assertSame( 'no', $rows[0]->autoload ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT "", + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT "yes", + PRIMARY KEY (option_id) + )' + ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $update + ) + ); + $this->assertSame( 2, $unique_index_metadata_queries ); } /** @@ -5175,6 +5214,197 @@ public function test_show_indexes_where_key_name_filters_catalog_rows(): void { $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); } + /** + * Tests catalog-backed MySQL introspection queries are cached until metadata changes. + */ + public function test_mysql_introspection_result_cache_reuses_catalog_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_options ( + option_id INTEGER, + option_name TEXT, + option_value TEXT, + autoload TEXT + )' + ); + + $describe_catalog_queries = 0; + $show_columns_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries, &$show_columns_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + if ( false !== strpos( $sql, 'show_columns_rows' ) ) { + ++$show_columns_catalog_queries; + } + } + ); + + $describe = $driver->query( 'DESC `wptests_options`;' ); + $describe[0]->Field = 'mutated'; + + $cached_describe = $driver->query( 'DESC `wptests_options`;' ); + + $this->assertSame( 1, $describe_catalog_queries ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'option_id', $cached_describe[0]->Field ); + $this->assertSame( 'Field', $driver->get_last_column_meta()[0]['name'] ); + + $columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + $columns[0]->Field = 'mutated'; + + $cached_columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + + $this->assertSame( 1, $show_columns_catalog_queries ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'option_id', $cached_columns[0]->Field ); + $this->assertSame( 'Null', $driver->get_last_column_meta()[2]['name'] ); + + $driver->query( 'ALTER TABLE wptests_options ADD KEY option_value (option_value)' ); + + $indexed_columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + + $this->assertSame( 2, $show_columns_catalog_queries ); + $this->assertSame( 'MUL', $indexed_columns[2]->Key ); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id) + )" + ); + $driver->query( 'DESC `wptests_options`;' ); + + $this->assertSame( 2, $describe_catalog_queries ); + + $index_driver = $this->create_show_index_driver(); + $show_index_catalog_queries = 0; + $index_driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$show_index_catalog_queries ): void { + if ( false !== strpos( $sql, 'show_index_fixture' ) ) { + ++$show_index_catalog_queries; + } + } + ); + + $indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + $indexes[0]->Key_name = 'mutated'; + + $cached_indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + + $this->assertSame( 1, $show_index_catalog_queries ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); + $this->assertSame( 'PRIMARY', $cached_indexes[0]->Key_name ); + $this->assertSame( 'Key_name', $index_driver->get_last_column_meta()[2]['name'] ); + } + + /** + * Tests introspection caching skips FETCH_FUNC closure arguments. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_closure_fetch_mode_args(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + $fetch_field = static function ( ...$values ) { + return $values[0]; + }; + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( 'option_id', $rows[0] ); + $this->assertSame( 'option_id', $cached_rows[0] ); + $this->assertSame( 2, $describe_catalog_queries ); + } + + /** + * Tests introspection caching skips FETCH_FUNC static callback results. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_static_callback_results(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + self::$mysql_introspection_fetch_func_invocations = 0; + $fetch_field = array( self::class, 'fetch_dynamic_field_for_introspection_cache_test' ); + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( + array( '1:option_id', '2:option_name', '3:option_value', '4:autoload' ), + $rows + ); + $this->assertSame( + array( '5:option_id', '6:option_name', '7:option_value', '8:autoload' ), + $cached_rows + ); + $this->assertSame( 8, self::$mysql_introspection_fetch_func_invocations ); + $this->assertSame( 2, $describe_catalog_queries ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + } + + /** + * Fetch a dynamic field value for the FETCH_FUNC introspection cache test. + * + * @param mixed ...$values Fetched row values. + * @return string Dynamic field value. + */ + public static function fetch_dynamic_field_for_introspection_cache_test( ...$values ): string { + ++self::$mysql_introspection_fetch_func_invocations; + return self::$mysql_introspection_fetch_func_invocations . ':' . $values[0]; + } + + /** + * Tests introspection caching skips FETCH_CLASS rows without public cloning. + */ + public function test_mysql_introspection_result_cache_skips_fetch_class_rows_without_public_clone(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + $fetch_class = $this->get_no_public_clone_fetch_row_class_name(); + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_CLASS, $fetch_class ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_CLASS, $fetch_class ); + + $this->assertInstanceOf( $fetch_class, $rows[0] ); + $this->assertInstanceOf( $fetch_class, $cached_rows[0] ); + $this->assertSame( 'option_id', $rows[0]->Field ); + $this->assertSame( 'option_id', $cached_rows[0]->Field ); + $this->assertSame( 2, $describe_catalog_queries ); + } + /** * Tests MySQL-only runtime SET statements are ignored before reaching PDO. */ @@ -5951,6 +6181,56 @@ private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, st return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Get a fetch row class name whose clone operation is not publicly callable. + * + * @return string Fetch row class name. + */ + private function get_no_public_clone_fetch_row_class_name(): string { + $class_name = 'WP_PostgreSQL_Driver_No_Public_Clone_Fetch_Row'; + if ( class_exists( $class_name, false ) ) { + return $class_name; + } + + $prototype = new class() { + /** + * Fetched values keyed by column name. + * + * @var array + */ + private $values = array(); + + /** + * Store a fetched column value. + * + * @param string $name Column name. + * @param mixed $value Column value. + */ + public function __set( string $name, $value ): void { + $this->values[ $name ] = $value; + } + + /** + * Get a fetched column value. + * + * @param string $name Column name. + * @return mixed Column value. + */ + public function __get( string $name ) { + return $this->values[ $name ] ?? null; + } + + /** + * Prevent public cloning. + */ + private function __clone() {} + }; + + class_alias( get_class( $prototype ), $class_name ); + + return $class_name; + } + /** * Creates a PostgreSQL driver with SHOW INDEX fixture rows. * From 701531c7a42717b88ad75a2f4c70877030e10adf Mon Sep 17 00:00:00 2001 From: adamziel Date: Sat, 13 Jun 2026 18:23:44 +0000 Subject: [PATCH 094/142] Bound PostgreSQL WordPress PHPUnit CI --- .github/workflows/wp-tests-phpunit.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 6b3dd813d..460850568 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -54,7 +54,7 @@ jobs: postgresql-test: name: WordPress PHPUnit Tests / PostgreSQL runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 20 continue-on-error: true permissions: contents: read # Required to clone the repo. @@ -74,6 +74,7 @@ jobs: - name: Run WordPress PHPUnit tests env: WP_TEST_DB_BACKEND: postgresql + WP_TEST_PHPUNIT_FILTER: '^Tests_DB::' run: node .github/workflows/wp-tests-phpunit-run.js - name: Upload PHPUnit count From 57422588e43afe59e458917d1c8a8f76c824e52b Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 00:36:09 +0000 Subject: [PATCH 095/142] Speed up PostgreSQL WordPress PHPUnit Full unfiltered PostgreSQL WordPress PHPUnit now passes locally in about 19 minutes. Use PostgreSQL setup and wrapper shortcuts, metadata and introspection caching, and shared-read savepoint reuse while preserving error isolation. Quick benchmark baseline is available under /tmp/cao-logs/sqlite-database-integration/full-suite-runtime-optimization-701531c/quick-benchmark-prepare-revision/. --- .github/workflows/wp-tests-phpunit-run.js | 120 ++- .github/workflows/wp-tests-phpunit.yml | 3 +- composer.json | 2 +- .../class-wp-postgresql-connection.php | 152 +++- .../postgresql/class-wp-postgresql-driver.php | 756 +++++++++++++++++- .../tests/WP_PostgreSQL_Connection_Tests.php | 157 +++- .../tests/WP_PostgreSQL_Driver_Tests.php | 351 +++++++- wp-setup.sh | 125 ++- 8 files changed, 1621 insertions(+), 45 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index e977ac950..b9615c9f1 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -136,18 +136,7 @@ try { let phpunitCommandError = null; try { - execFileSync( - 'composer', - [ - 'run', - 'wp-test-php', - '--', - '--log-junit=phpunit-results.xml', - '--verbose', - ...phpunitArgs, - ], - { stdio: 'inherit' } - ); + runPhpUnit(); console.log( '\nAll tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { phpunitCommandError = error; @@ -291,6 +280,68 @@ function verifyContainerPhpExtension( service, verifier ) { ); } +function runPhpUnit() { + const args = [ + '--log-junit=phpunit-results.xml', + '--verbose', + ...phpunitArgs, + ]; + + if ( 'postgresql' === backend ) { + removeWordPressSqliteHtAccessFile(); + execFileSync( + 'docker', + [ + 'compose', + ...getWordPressDockerComposeArgs(), + 'run', + '--rm', + 'php', + './vendor/bin/phpunit', + ...args, + ], + { + cwd: path.join( repositoryRoot, 'wordpress' ), + env: { + ...process.env, + COMPOSE_IGNORE_ORPHANS: 'true', + }, + stdio: 'inherit', + } + ); + return; + } + + execFileSync( + 'composer', + [ + 'run', + 'wp-test-php', + '--', + ...args, + ], + { stdio: 'inherit' } + ); +} + +function getWordPressDockerComposeArgs() { + const args = [ '-f', 'docker-compose.yml' ]; + if ( fs.existsSync( path.join( repositoryRoot, 'wordpress', 'docker-compose.override.yml' ) ) ) { + args.push( '-f', 'docker-compose.override.yml' ); + } + + return args; +} + +function removeWordPressSqliteHtAccessFile() { + fs.rmSync( + path.join( repositoryRoot, 'wordpress', 'src', 'wp-content', 'database', '.ht.sqlite' ), + { + force: true, + } + ); +} + function ensureWordPressTestEnvironment() { if ( 'postgresql' === backend ) { ensurePostgreSqlWordPressTestEnvironment(); @@ -429,6 +480,13 @@ function validateGeneratedBackendFiles() { '\nvolumes:\n mysql: !reset null\n postgres: {}', 'docker-compose.override.yml removes the inherited MySQL volume' ); + for ( const setting of [ 'fsync=off', 'synchronous_commit=off', 'full_page_writes=off' ] ) { + assertFileContains( + composeOverride, + setting, + `docker-compose.override.yml sets PostgreSQL ${ setting } for test runs` + ); + } assertFileContains( postgresqlPhpDockerfile, 'docker-php-ext-install pdo_pgsql', @@ -441,8 +499,28 @@ function validateGeneratedBackendFiles() { ); assertFileContains( installScript, - "const { existsSync, renameSync, readFileSync, writeFileSync } = require( 'fs' );", - 'install.js imports guarded wp-config file helpers' + "const fs = require( 'fs' );", + 'install.js imports the fs object for direct PostgreSQL setup' + ); + assertFileContains( + installScript, + "const { existsSync, renameSync, readFileSync, writeFileSync } = fs;", + 'install.js imports guarded wp-config file helpers from fs' + ); + assertFileContains( + installScript, + 'install_postgresql_test_environment();', + 'install.js runs the direct PostgreSQL test-environment setup path' + ); + assertFileContains( + installScript, + 'write_postgresql_wp_config();', + 'install.js writes wp-config.php without WP-CLI for PostgreSQL' + ); + assertFileContains( + installScript, + 'write_postgresql_wp_tests_config();', + 'install.js writes wp-tests-config.php without WP-CLI for PostgreSQL' ); assertFileContains( installScript, @@ -484,10 +562,20 @@ function validateGeneratedBackendFiles() { "wp_cli( 'db reset --yes' );", 'install.js does not call the MySQL-backed db reset command for PostgreSQL' ); - assertFileDoesNotContain( + assertFileContains( installScript, 'install_wp_importer();', - 'install.js does not call WP-CLI plugin installation for PostgreSQL' + 'install.js installs the WordPress Importer test plugin for PostgreSQL' + ); + assertFileContains( + installScript, + 'run --rm --workdir /var/www php git clone https://github.com/WordPress/wordpress-importer.git', + 'install.js runs the WordPress Importer clone from a valid PostgreSQL container workdir' + ); + assertFileContains( + installScript, + 'git clone https://github.com/WordPress/wordpress-importer.git \' + testPluginDirectory + \' --depth=1', + 'install.js clones the WordPress Importer directly for the fast PostgreSQL setup path' ); assertFileDoesNotContain( installScript, diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 460850568..6b3dd813d 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -54,7 +54,7 @@ jobs: postgresql-test: name: WordPress PHPUnit Tests / PostgreSQL runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 continue-on-error: true permissions: contents: read # Required to clone the repo. @@ -74,7 +74,6 @@ jobs: - name: Run WordPress PHPUnit tests env: WP_TEST_DB_BACKEND: postgresql - WP_TEST_PHPUNIT_FILTER: '^Tests_DB::' run: node .github/workflows/wp-tests-phpunit-run.js - name: Upload PHPUnit count diff --git a/composer.json b/composer.json index 3b344bca8..2e50fdd11 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "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 ) { 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 }` ] ] ); } 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 { existsSync, renameSync, readFileSync, writeFileSync } = require( ${ quote }fs${ quote } );`, `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", + "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 ) { 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 }` ] ] ); } 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 = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", "wp-test-ensure-env": [ "@wp-test-ensure-backend @no_additional_args", "@putenv COMPOSE_IGNORE_ORPHANS=true", diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index e4551f5fe..d5092887c 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -49,6 +49,20 @@ class WP_PostgreSQL_Connection { */ private $savepoint_counter = 0; + /** + * Active generated savepoint shared by consecutive read-only statements. + * + * @var string|null + */ + private $active_read_savepoint = null; + + /** + * Whether the active read savepoint still needs to be created in PostgreSQL. + * + * @var bool + */ + private $active_read_savepoint_needs_creation = false; + /** * Constructor. * @@ -138,19 +152,23 @@ public function query( string $sql, array $params = array() ): PDOStatement { ( $this->query_logger )( $sql, $params ); } - $savepoint = $this->get_statement_savepoint_name( $sql ); - if ( null !== $savepoint ) { - $this->pdo->exec( 'SAVEPOINT ' . $savepoint ); + $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 ) { - $this->pdo->exec( 'RELEASE SAVEPOINT ' . $savepoint ); + 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 ) { @@ -173,6 +191,7 @@ 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 ); } @@ -245,6 +264,14 @@ 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. * @@ -252,9 +279,20 @@ public function set_query_logger( callable $logger ): void { * 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 ): ?string { + private function get_statement_savepoint_name( string $sql, bool &$release_savepoint_on_success, bool &$savepoint_exists ): ?string { + $release_savepoint_on_success = false; + $savepoint_exists = false; + if ( 'pgsql' !== $this->get_driver_name() || ! $this->pdo->inTransaction() @@ -263,6 +301,20 @@ private function get_statement_savepoint_name( string $sql ): ?string { 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; } @@ -280,6 +332,91 @@ private function is_postgresql_transaction_control_statement( string $sql ): boo ); } + /** + * 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. * @@ -287,6 +424,9 @@ private function is_postgresql_transaction_control_statement( string $sql ): boo */ 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 ) { 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 index 1bd5572c4..3c3b72d45 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -291,6 +291,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $this->reset_query_state(); $this->last_mysql_query = $query; + if ( $this->is_fast_noop_mysql_runtime_setting( $query ) ) { + $this->last_result = 0; + return $this->last_result; + } + if ( $this->is_noop_mysql_runtime_setting( $query ) ) { $this->last_result = 0; return $this->last_result; @@ -301,6 +306,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + $transaction_control_query = $this->get_mysql_transaction_control_query( $query ); + if ( null !== $transaction_control_query ) { + return $this->execute_mysql_transaction_control_query( $transaction_control_query ); + } + $procedure_result = $this->handle_mysql_procedure_query( $query, $fetch_mode, ...$fetch_mode_args ); if ( null !== $procedure_result ) { return $procedure_result; @@ -503,6 +513,18 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $translated_query = $this->translate_wordpress_term_cache_priming_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_wordpress_approved_comments_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -708,6 +730,11 @@ private function get_sql_calc_found_rows_count_query( string $query ): ?string { * @return string|null PostgreSQL SELECT query, or null when unsupported. */ private function translate_sql_calc_found_rows_count_select_query( string $query ): ?string { + $query = $this->get_sql_calc_found_rows_count_source_query( $query ); + if ( null === $query ) { + return null; + } + $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query, false ); if ( null !== $translated_query ) { return $translated_query; @@ -721,6 +748,35 @@ private function translate_sql_calc_found_rows_count_select_query( string $query return $this->translate_sql_calc_found_rows_select_query( $query, false ); } + /** + * Build the unordered, unbounded MySQL SELECT used for FOUND_ROWS accounting. + * + * @param string $query MySQL query. + * @return string|null MySQL query without top-level ORDER BY or LIMIT clauses. + */ + private function get_sql_calc_found_rows_count_source_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + $count_end = $order_position ?? $select_end; + + return rtrim( substr( $query, 0, $tokens[ $count_end ]->start ) ); + } + /** * Execute translated PostgreSQL statements for a single MySQL-facing query. * @@ -741,6 +797,113 @@ private function execute_postgresql_statements( array $statements ) { return $this->last_result; } + /** + * Execute a simple MySQL transaction-control statement in PostgreSQL. + * + * @param string $statement Canonical PostgreSQL transaction statement. + * @return int Number of affected rows. + */ + private function execute_mysql_transaction_control_query( string $statement ): int { + $pdo = $this->connection->get_pdo(); + $in_transaction = $pdo->inTransaction(); + + if ( 'BEGIN' === $statement ) { + if ( $in_transaction ) { + $pdo->commit(); + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => 'COMMIT', + 'params' => array(), + ); + } + $pdo->beginTransaction(); + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => 'BEGIN', + 'params' => array(), + ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( ! $in_transaction ) { + $this->connection->reset_statement_savepoint_state(); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( 'COMMIT' === $statement ) { + $pdo->commit(); + } else { + $pdo->rollBack(); + } + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + /** + * Check whether a MySQL runtime setting can be ignored without tokenization. + * + * This covers the high-frequency WordPress PHPUnit transaction setup path. + * Less common supported SET shapes still fall through to the lexer-backed + * runtime-setting handler. + * + * @param string $query MySQL query. + * @return bool Whether the query should be treated as a successful no-op. + */ + private function is_fast_noop_mysql_runtime_setting( string $query ): bool { + return 1 === preg_match( + '/\A\s*SET\s+(?:(?:GLOBAL|LOCAL|SESSION)\s+)?(?:autocommit|default_storage_engine|foreign_key_checks|sql_mode|storage_engine)\s*=\s*(?:\'[^\']*\'|"[^"]*"|[^\s,;]+)\s*;?\s*\z/i', + $query + ); + } + + /** + * Get the canonical PostgreSQL transaction statement for a simple MySQL query. + * + * @param string $query MySQL query. + * @return string|null Canonical PostgreSQL statement, or null when unsupported. + */ + private function get_mysql_transaction_control_query( string $query ): ?string { + $statement = trim( $query ); + $statement = preg_replace( '/;\s*\z/', '', $statement ); + if ( null === $statement ) { + return null; + } + + $normalized_statement = preg_replace( '/\s+/', ' ', $statement ); + if ( null === $normalized_statement ) { + return null; + } + + $statement = trim( $normalized_statement ); + if ( '' === $statement ) { + return null; + } + + if ( 1 === preg_match( '/\A(?:START TRANSACTION|BEGIN(?: WORK)?)\z/i', $statement ) ) { + return 'BEGIN'; + } + + if ( 1 === preg_match( '/\ACOMMIT(?: WORK)?\z/i', $statement ) ) { + return 'COMMIT'; + } + + if ( 1 === preg_match( '/\AROLLBACK(?: WORK)?\z/i', $statement ) ) { + return 'ROLLBACK'; + } + + return null; + } + /** * Apply a supported MySQL SET NAMES statement to the emulated session. * @@ -3510,6 +3673,7 @@ public function get_last_column_meta(): array { */ public function beginTransaction(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $this->connection->get_pdo()->beginTransaction(); + $this->connection->reset_statement_savepoint_state(); } /** @@ -3526,6 +3690,7 @@ public function begin_transaction(): void { */ public function commit(): void { $this->connection->get_pdo()->commit(); + $this->connection->reset_statement_savepoint_state(); } /** @@ -3533,6 +3698,7 @@ public function commit(): void { */ public function rollback(): void { $this->connection->get_pdo()->rollBack(); + $this->connection->reset_statement_savepoint_state(); } /** @@ -5636,21 +5802,26 @@ private function translate_simple_mysql_select_query( string $query ): ?string { $this->translate_mysql_identifier_token_to_postgresql( $table_token ) ); + $scope = $this->get_mysql_single_table_scope( $table_name ); if ( null !== $where_position ) { $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( $tokens, $where_position + 1, $where_end, - $this->get_mysql_single_table_scope( $table_name ) + $scope ); $sql .= ' WHERE ' . $where_sql['sql']; } if ( null !== $order_position ) { - $sql .= ' ORDER BY ' . $this->translate_mysql_token_to_postgresql( $tokens[ $order_position + 2 ] ); - if ( isset( $tokens[ $order_position + 3 ] ) && $order_position + 3 < $select_end ) { - $sql .= ' ' . $tokens[ $order_position + 3 ]->get_bytes(); - } + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $select_end, + $scope, + false + ); + $sql .= ' ORDER BY ' . $order_sql['sql']; $tiebreaker_sql = $this->get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, @@ -5661,6 +5832,18 @@ private function translate_simple_mysql_select_query( string $query ): ?string { if ( null !== $tiebreaker_sql ) { $sql .= ', ' . $tiebreaker_sql; } + + $tiebreaker_sql = $this->get_simple_wordpress_approved_comments_order_tiebreaker_sql( + $tokens, + $table_name, + $where_position, + $where_end, + $order_position, + $select_end + ); + if ( null !== $tiebreaker_sql ) { + $sql .= ', ' . $tiebreaker_sql; + } } if ( null !== $limit_position ) { @@ -6420,6 +6603,240 @@ private function translate_wordpress_available_post_mime_types_query( string $qu ); } + /** + * Translate WordPress term cache priming with MySQL-compatible shared-term order. + * + * WordPress primes term objects with a join query that has no ORDER BY. The + * cache is keyed by term_id, so legacy shared terms rely on MySQL returning + * rows in term_taxonomy_id order and letting the last taxonomy row win. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_term_cache_priming_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $from_position ); + if ( + null === $projection_ranges + || 2 !== count( $projection_ranges ) + || ! $this->is_mysql_qualified_star_projection( $tokens, $projection_ranges[0]['start'], $projection_ranges[0]['end'], 't' ) + || ! $this->is_mysql_qualified_star_projection( $tokens, $projection_ranges[1]['start'], $projection_ranges[1]['end'], 'tt' ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $statement_end ); + if ( + null === $where_position + || ! $this->is_mysql_distinct_term_taxonomy_from_shape( $tokens, $from_position, $where_position ) + || ! $this->is_mysql_term_cache_priming_where_clause( $tokens, $where_position + 1, $statement_end ) + ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ) . ' ORDER BY tt.term_taxonomy_id ASC'; + } + + /** + * Translate WordPress's approved comments lookup with MySQL-compatible ties. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_approved_comments_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::MULT_OPERATOR !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 2, $statement_end ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $select_end ); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 2, $select_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $select_end ); + if ( + 2 !== $from_position + || null === $where_position + || null === $order_position + || $from_position + 2 !== $where_position + || $where_position >= $order_position + || $order_position + 2 >= $select_end + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + ) { + return null; + } + + $table_token = $tokens[ $from_position + 1 ] ?? null; + $table_name = $this->get_mysql_identifier_token_value( $table_token ); + if ( null === $table_name || ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) ) { + return null; + } + + $tiebreaker_sql = $this->get_simple_wordpress_approved_comments_order_tiebreaker_sql( + $tokens, + $table_name, + $where_position, + $order_position, + $order_position, + $select_end + ); + if ( null === $tiebreaker_sql ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name ); + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $order_position, + $scope + ); + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $select_end, + $scope, + false + ); + + $sql = sprintf( + 'SELECT * FROM %s WHERE %s ORDER BY %s, %s', + $this->translate_mysql_identifier_token_to_postgresql( $table_token ), + $where_sql['sql'], + $order_sql['sql'], + $tiebreaker_sql + ); + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Check whether a projection item is alias.*. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $alias Expected table alias. + * @return bool Whether the projection item is the qualified star. + */ + private function is_mysql_qualified_star_projection( array $tokens, int $start, int $end, string $alias ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return $start + 3 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, $alias ) + && isset( $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start + 2 ]->id; + } + + /** + * Check whether a WHERE clause is t.term_id IN (integer list). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether the WHERE clause matches term cache priming. + */ + private function is_mysql_term_cache_priming_where_clause( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] + 3 > $end + || 't' !== strtolower( (string) $reference['qualifier'] ) + || 'term_id' !== strtolower( $reference['column'] ) + || ! isset( $tokens[ $reference['end'] ], $tokens[ $reference['end'] + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $reference['end'] ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $reference['end'] + 1 ]->id + ) { + return false; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $end ); + if ( $after_close !== $end ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $end - 1 ); + if ( null === $items || empty( $items ) ) { + return false; + } + + foreach ( $items as $item ) { + if ( + $item['start'] + 1 !== $item['end'] + || ! isset( $tokens[ $item['start'] ] ) + || ! in_array( + $tokens[ $item['start'] ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return false; + } + } + + return true; + } + /** * Check whether a token is an unsupported SELECT modifier for this rewrite. * @@ -7221,6 +7638,17 @@ private function translate_strict_grouped_order_by_query( return null; } + $tiebreaker_sql = $this->get_strict_grouped_posts_post_date_desc_order_id_tiebreaker_sql( + $tokens, + $order_items, + $group_items, + $is_post_id_group + ); + if ( null !== $tiebreaker_sql ) { + $order_sql[] = $tiebreaker_sql; + $rewritten = true; + } + if ( ! $rewritten ) { return null; } @@ -9614,6 +10042,45 @@ private function get_strict_grouped_aggregate_order_sql( array $order_item ): st ); } + /** + * Get the MySQL-compatible ID tie-breaker for grouped posts date ordering. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param bool $is_post_id_group Whether the query groups by posts.ID. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_strict_grouped_posts_post_date_desc_order_id_tiebreaker_sql( + array $tokens, + array $order_items, + array $group_items, + bool $is_post_id_group + ): ?string { + if ( + ! $is_post_id_group + || 1 !== count( $order_items ) + || 1 !== count( $group_items ) + || 'DESC' !== $order_items[0]['direction'] + || ! $this->is_mysql_column_reference_expression( + $tokens, + $order_items[0]['expression_start'], + $order_items[0]['expression_end'], + 'post_date', + 'posts', + false + ) + ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ) . ' DESC'; + } + /** * Check whether an expression is a supported column reference. * @@ -10650,6 +11117,218 @@ private function get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_s return $this->connection->quote_identifier( 'ID' ) . ' DESC'; } + /** + * Get the MySQL-compatible approved-comments date tie-breaker. + * + * get_approved_comments() orders by comment_date_gmt only. MySQL returns + * equal-date rows in comment_ID order for WordPress's comments table shape, + * so make that ordering explicit for PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param string $table_name Selected table name. + * @param int|null $where_position WHERE token position, or null. + * @param int|null $where_end Final WHERE token position, exclusive. + * @param int $order_position ORDER token position. + * @param int $end Final ORDER BY token position, exclusive. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_simple_wordpress_approved_comments_order_tiebreaker_sql( + array $tokens, + string $table_name, + ?int $where_position, + ?int $where_end, + int $order_position, + int $end + ): ?string { + if ( + ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) + || null === $where_position + || null === $where_end + || ! $this->is_simple_wordpress_approved_comments_where_clause( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $order_items = $this->split_top_level_mysql_arguments( $tokens, $order_position + 2, $end ); + if ( null === $order_items || 1 !== count( $order_items ) ) { + return null; + } + + $order_item = $order_items[0]; + $direction = 'ASC'; + $item_end = $order_item['end']; + if ( isset( $tokens[ $item_end - 1 ] ) ) { + if ( WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + return null; + } + if ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $item_end = $item_end - 1; + $direction = 'ASC'; + } + } + + if ( + 'ASC' !== $direction + || ! $this->is_mysql_column_reference_expression( + $tokens, + $order_item['start'], + $item_end, + 'comment_date_gmt', + 'comments', + true + ) + ) { + return null; + } + + return $this->connection->quote_identifier( 'comment_ID' ) . ' ASC'; + } + + /** + * Check for get_approved_comments()'s single-post approved comments filter. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether the WHERE clause matches the approved-comments shape. + */ + private function is_simple_wordpress_approved_comments_where_clause( array $tokens, int $start, int $end ): bool { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return false; + } + + $has_post_id = false; + $has_approved = false; + foreach ( $conjuncts as $conjunct ) { + $match = $this->get_simple_wordpress_comments_literal_equality( + $tokens, + $conjunct['start'], + $conjunct['end'] + ); + if ( null === $match ) { + continue; + } + + if ( 'comment_post_id' === $match['column'] ) { + $has_post_id = true; + continue; + } + + if ( 'comment_approved' === $match['column'] && '1' === $match['value'] ) { + $has_approved = true; + } + } + + return $has_post_id && $has_approved; + } + + /** + * Parse a comments-table column = literal predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token. + * @param int $end Final predicate token, exclusive. + * @return array{column: string, value: string}|null Parsed column and literal value. + */ + private function get_simple_wordpress_comments_literal_equality( array $tokens, int $start, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $equal_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $start, $end ); + if ( + null === $equal_position + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $equal_position + 1, $end ) + ) { + return null; + } + + $match = $this->get_simple_wordpress_comments_literal_equality_side( + $tokens, + $start, + $equal_position, + $equal_position + 1, + $end + ); + if ( null !== $match ) { + return $match; + } + + return $this->get_simple_wordpress_comments_literal_equality_side( + $tokens, + $equal_position + 1, + $end, + $start, + $equal_position + ); + } + + /** + * Parse one column/literal side of a comments-table equality predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $column_start First column token. + * @param int $column_end Final column token, exclusive. + * @param int $literal_start First literal token. + * @param int $literal_end Final literal token, exclusive. + * @return array{column: string, value: string}|null Parsed column and literal value. + */ + private function get_simple_wordpress_comments_literal_equality_side( + array $tokens, + int $column_start, + int $column_end, + int $literal_start, + int $literal_end + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $column_start, $column_end ); + if ( + null === $reference + || $reference['end'] !== $column_end + || ( + null !== $reference['qualifier'] + && ! $this->is_mysql_wordpress_table_name( $reference['qualifier'], 'comments' ) + ) + ) { + return null; + } + + $literal = $this->get_simple_wordpress_comments_literal_value( $tokens, $literal_start, $literal_end ); + if ( null === $literal ) { + return null; + } + + return array( + 'column' => strtolower( $reference['column'] ), + 'value' => $literal, + ); + } + + /** + * Get a supported literal value for the approved-comments WHERE predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return string|null Literal value, "literal" for unconstrained post IDs, or null. + */ + private function get_simple_wordpress_comments_literal_value( array $tokens, int $start, int $end ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return $tokens[ $start ]->get_value(); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null !== $literal && $literal['end'] === $end ) { + return 'literal'; + } + + return null; + } + /** * Get the MySQL-compatible posts date tie-breaker for a parsed ORDER BY. * @@ -10803,6 +11482,15 @@ private function translate_mysql_expression_token_sequence_to_postgresql( $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, @@ -10843,6 +11531,50 @@ private function translate_mysql_expression_token_sequence_to_postgresql( ); } + /** + * Translate WordPress text ORDER BY expressions with MySQL collation semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate expression start position. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_order_expression_to_postgresql( + array $tokens, + int $position, + int $start, + int $end, + array $scope + ): ?array { + if ( $position !== $start ) { + return null; + } + + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + if ( $bounds['start'] !== $start || $bounds['end'] !== $end ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $bounds['start'], $bounds['end'] ); + if ( + null === $reference + || $reference['end'] !== $bounds['end'] + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $bounds['end'] - 1, + ); + } + /** * Translate WordPress text predicates embedded in expressions. * @@ -11489,6 +12221,20 @@ private function is_mysql_wordpress_case_insensitive_text_column( string $table_ return 'meta_value' === $column_name; } + if ( $this->is_mysql_wordpress_table_name( $table_name, 'users' ) ) { + return in_array( + $column_name, + array( + 'display_name', + 'user_email', + 'user_login', + 'user_nicename', + 'user_url', + ), + true + ); + } + return false; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php index 7f1b31727..a4ae4c8bf 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -215,12 +215,117 @@ public function test_query_rolls_back_failed_postgresql_statement_to_transaction 'ROLLBACK TO SAVEPOINT wp_statement_3', 'RELEASE SAVEPOINT wp_statement_3', 'SAVEPOINT wp_statement_4', - 'RELEASE SAVEPOINT wp_statement_4', ), $pdo->exec_sql ); } + /** + * Tests consecutive plain SELECT statements reuse one generated read savepoint. + */ + public function test_query_reuses_read_savepoint_for_consecutive_plain_select_statements(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $first = $connection->query( 'SELECT 1 AS value' ); + $second = $connection->query( 'SELECT 2 AS value' ); + $connection->query( 'CREATE TABLE t (id INTEGER)' ); + + $this->assertSame( '1', $first->fetchColumn() ); + $this->assertSame( '2', $second->fetchColumn() ); + $this->assertSame( + array( 'SELECT 1 AS value', 'SELECT 2 AS value', 'CREATE TABLE t (id INTEGER)' ), + $pdo->prepared_sql + ); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + ), + $pdo->exec_sql + ); + $pdo->rollBack(); + } + + /** + * Tests failed read statements are isolated from the active PostgreSQL transaction. + */ + public function test_query_rolls_back_failed_read_to_shared_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + + try { + $connection->query( 'SELECT missing_column FROM t' ); + $this->fail( 'Expected the invalid read to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_column', $exception->getMessage() ); + } + + $stmt = $connection->query( 'SELECT 1 AS value' ); + + $this->assertSame( '1', $stmt->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'ROLLBACK TO SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + ), + $pdo->exec_sql + ); + } + + /** + * Tests locking SELECT statements use per-statement savepoints. + * + * @dataProvider data_locking_select_statements + * + * @param string $sql Locking SELECT statement. + */ + public function test_query_wraps_locking_select_statement_in_per_statement_savepoint( string $sql ): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + + try { + $connection->query( $sql ); + $this->fail( 'Expected SQLite to reject the PostgreSQL/MySQL locking SELECT shape.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'ROLLBACK TO SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + ), + $pdo->exec_sql + ); + } + + /** + * Provides locking SELECT statements. + * + * @return array + */ + public function data_locking_select_statements(): array { + return array( + 'for_update' => array( 'SELECT 1 FOR UPDATE' ), + 'for_share' => array( 'SELECT 1 FOR SHARE' ), + 'lock_in_share_mode' => array( 'SELECT 1 LOCK IN SHARE MODE' ), + ); + } + /** * Tests transaction-control statements are not wrapped in generated savepoints. */ @@ -255,6 +360,56 @@ function ( string $sql, array $params ) use ( &$log ): void { $this->assertSame( array( array( 'SELECT ? AS value', array() ) ), $log ); } + /** + * Tests prepare consumes an active read savepoint before returning a statement. + */ + public function test_prepare_consumes_active_read_savepoint_before_prepared_write(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + $connection->query( 'SELECT 1' ); + + $stmt = $connection->prepare( 'INSERT INTO t (id, value) VALUES (1, ?)' ); + $stmt->execute( array( 'kept' ) ); + + try { + $connection->query( 'SELECT missing_column FROM t' ); + $this->fail( 'Expected the invalid read to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_column', $exception->getMessage() ); + } + + $count = $connection->query( 'SELECT COUNT(*) FROM t' ); + + $this->assertSame( '1', $count->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)', + 'SELECT 1', + 'INSERT INTO t (id, value) VALUES (1, ?)', + 'SELECT missing_column FROM t', + 'SELECT COUNT(*) FROM t', + ), + $pdo->prepared_sql + ); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + 'ROLLBACK TO SAVEPOINT wp_statement_3', + 'RELEASE SAVEPOINT wp_statement_3', + 'SAVEPOINT wp_statement_4', + ), + $pdo->exec_sql + ); + } + /** * Tests last insert ID delegates to the injected PDO. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 9272f1166..7d6821980 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -991,6 +991,45 @@ public function test_simple_select_with_mixed_case_comment_identifiers_is_transl ); } + /** + * 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 MySQL offset,count LIMIT syntax is translated to PostgreSQL. */ @@ -2069,7 +2108,7 @@ static function ( $row ) { 'params' => array(), ), array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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, wptests_posts."ID" DESC) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))) AS "__wp_pg_found_rows"', 'params' => array(), ), ), @@ -2100,7 +2139,51 @@ public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebrea '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\' ORDER BY wptests_posts.post_date ASC) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\') AS "__wp_pg_found_rows"', + '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(), ), ), @@ -2152,7 +2235,7 @@ static function ( $row ): string { ) ); $this->assertStringContainsString( - 'ORDER BY wptests_posts.menu_order ASC, wptests_posts.post_title ASC, wptests_posts."ID" ASC', + 'ORDER BY wptests_posts.menu_order ASC, LOWER(wptests_posts.post_title) ASC, wptests_posts."ID" ASC', $driver->get_last_postgresql_queries()[0]['sql'] ); } @@ -2304,7 +2387,7 @@ public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_p '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), $sql ); - $this->assertStringContainsString( "user_login LIKE '%yololololo%'", $sql ); + $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); } /** @@ -2669,6 +2752,55 @@ static function ( $row ): string { ); } + /** + * 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. */ @@ -3076,6 +3208,44 @@ public function test_grouped_distinct_term_query_order_by_name_preserves_visible ); } + /** + * 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. */ @@ -3274,7 +3444,7 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v 'params' => array(), ), array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT 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, wptests_posts."ID" DESC) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))) AS "__wp_pg_found_rows"', 'params' => array(), ), ), @@ -3372,7 +3542,7 @@ public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_ord $queries[0]['sql'] ); $this->assertSame( - 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (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) AS "__wp_pg_found_rows"', + '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'] ); @@ -3729,6 +3899,86 @@ public function test_decimal_cast_like_uses_text_without_changing_numeric_compar $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. */ @@ -5367,6 +5617,42 @@ static function ( string $sql ) use ( &$describe_catalog_queries ): void { $this->assertCount( 1, $driver->get_last_postgresql_queries() ); } + /** + * Tests introspection caching skips FETCH_FUNC named function results. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_named_function_results(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + global $wp_postgresql_driver_named_fetch_func_invocations; + $wp_postgresql_driver_named_fetch_func_invocations = 0; + $fetch_field = 'wp_postgresql_driver_fetch_dynamic_field_for_introspection_cache_test'; + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( + array( '1:option_id', '2:option_name', '3:option_value', '4:autoload' ), + $rows + ); + $this->assertSame( + array( '5:option_id', '6:option_name', '7:option_value', '8:autoload' ), + $cached_rows + ); + $this->assertSame( 8, $wp_postgresql_driver_named_fetch_func_invocations ); + $this->assertSame( 2, $describe_catalog_queries ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + } + /** * Fetch a dynamic field value for the FETCH_FUNC introspection cache test. * @@ -5434,6 +5720,46 @@ public function test_mysql_runtime_set_statements_are_noops(): void { } } + /** + * Tests simple MySQL transaction-control statements use direct backend statements. + */ + public function test_mysql_transaction_control_statements_use_fast_backend_path(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION;' ) ); + $this->assertSame( 'START TRANSACTION;', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'BEGIN', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'CREATE TABLE transaction_test (id INTEGER)' ); + $driver->query( 'INSERT INTO transaction_test (id) VALUES (1)' ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK' ) ); + $this->assertSame( + array( + array( + 'sql' => 'ROLLBACK', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $tables = $driver->query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transaction_test'" ); + $this->assertSame( array(), $tables ); + + $this->assertSame( 0, $driver->query( 'COMMIT' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + /** * Tests the emulated MySQL session SQL mode can be selected. */ @@ -6352,3 +6678,16 @@ private function install_information_schema_fixture( WP_PostgreSQL_Driver $drive ); } } + +/** + * Fetch a dynamic field value for the named-function FETCH_FUNC cache test. + * + * @param mixed ...$values Fetched row values. + * @return string Dynamic field value. + */ +function wp_postgresql_driver_fetch_dynamic_field_for_introspection_cache_test( ...$values ): string { + global $wp_postgresql_driver_named_fetch_func_invocations; + + ++$wp_postgresql_driver_named_fetch_func_invocations; + return $wp_postgresql_driver_named_fetch_func_invocations . ':' . $values[0]; +} diff --git a/wp-setup.sh b/wp-setup.sh index 77427dd42..dbb54d2e6 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -205,6 +205,14 @@ services: postgres: image: postgres:16-alpine + command: + - postgres + - -c + - fsync=off + - -c + - synchronous_commit=off + - -c + - full_page_writes=off networks: - wpdevnet ports: @@ -256,10 +264,20 @@ 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 { existsSync, renameSync, readFileSync, writeFileSync } = require( 'fs' );", + "const fs = require( 'fs' );", + "const { existsSync, renameSync, readFileSync, writeFileSync } = fs;", ], }, { @@ -295,12 +313,6 @@ const replacements = [ "\t.concat( \"define( 'FS_METHOD', 'direct' );\\n\" );", ], }, - { - from: "install_wp_importer();", - to: [ - "// Skip WP-CLI plugin installation until the PostgreSQL runtime is wired.", - ], - }, { from: "\t\twp_cli( 'db reset --yes' );", to: [ @@ -356,7 +368,104 @@ for ( const replacement of replacements ) { } } -fs.writeFileSync( file, output.join( '\n' ) ); +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 From 394f5fd447a2b343fdb68bc041f779bba8bd48e5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 01:19:44 +0000 Subject: [PATCH 096/142] Cache PostgreSQL column metadata lookups --- .../postgresql/class-wp-postgresql-driver.php | 70 +++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 69 ++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) 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 index 3c3b72d45..20b20017a 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -104,6 +104,27 @@ class WP_PostgreSQL_Driver { */ private $mysql_dml_column_metadata_cache = array(); + /** + * MySQL column type metadata keyed by backend schema, table, and column. + * + * @var array> + */ + private $mysql_table_column_type_cache = array(); + + /** + * MySQL column collation metadata keyed by backend schema, table, and column. + * + * @var array> + */ + private $mysql_table_column_collation_cache = array(); + + /** + * Stored MySQL column metadata existence keyed by backend schema and table. + * + * @var array + */ + private $mysql_table_has_column_metadata_cache = array(); + /** * Cached MySQL upsert conflict targets keyed by table and inserted columns. * @@ -1000,6 +1021,9 @@ private function ensure_mysql_schema_metadata_tables(): void { private function clear_mysql_metadata_caches(): void { $this->mysql_table_schema_introspection_cache = array(); $this->mysql_dml_column_metadata_cache = array(); + $this->mysql_table_column_type_cache = array(); + $this->mysql_table_column_collation_cache = array(); + $this->mysql_table_has_column_metadata_cache = array(); $this->mysql_upsert_conflict_target_cache = array(); $this->mysql_introspection_result_cache = array(); } @@ -1011,7 +1035,13 @@ private function clear_mysql_metadata_caches(): void { * @param string $table_name Table name. */ private function clear_mysql_metadata_cache_for_table( string $table_schema, string $table_name ): void { - unset( $this->mysql_dml_column_metadata_cache[ $this->get_mysql_metadata_cache_key( $table_schema, $table_name ) ] ); + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + unset( + $this->mysql_dml_column_metadata_cache[ $cache_key ], + $this->mysql_table_column_type_cache[ $cache_key ], + $this->mysql_table_column_collation_cache[ $cache_key ], + $this->mysql_table_has_column_metadata_cache[ $cache_key ] + ); $this->mysql_upsert_conflict_target_cache = array(); $this->mysql_introspection_result_cache = array(); @@ -1521,6 +1551,15 @@ private function get_mysql_table_column_type( ): ?string { $this->ensure_mysql_schema_metadata_tables(); + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = strtolower( $column_name ); + if ( + isset( $this->mysql_table_column_type_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_type_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ]; + } + $stmt = $this->connection->query( sprintf( 'SELECT column_type FROM %s @@ -1533,7 +1572,11 @@ private function get_mysql_table_column_type( ); $column_type = $stmt->fetchColumn(); - return false === $column_type ? null : (string) $column_type; + $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ] = false === $column_type + ? null + : (string) $column_type; + + return $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ]; } /** @@ -1551,6 +1594,15 @@ private function get_mysql_table_column_collation( ): ?string { $this->ensure_mysql_schema_metadata_tables(); + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = strtolower( $column_name ); + if ( + isset( $this->mysql_table_column_collation_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_collation_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ]; + } + $stmt = $this->connection->query( sprintf( 'SELECT collation_name FROM %s @@ -1563,7 +1615,11 @@ private function get_mysql_table_column_collation( ); $collation = $stmt->fetchColumn(); - return false === $collation || null === $collation ? null : (string) $collation; + $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ] = false === $collation || null === $collation + ? null + : (string) $collation; + + return $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ]; } /** @@ -1576,6 +1632,11 @@ private function get_mysql_table_column_collation( private function mysql_table_has_column_metadata( string $table_schema, string $table_name ): bool { $this->ensure_mysql_schema_metadata_tables(); + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_table_has_column_metadata_cache ) ) { + return $this->mysql_table_has_column_metadata_cache[ $cache_key ]; + } + $stmt = $this->connection->query( sprintf( 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? LIMIT 1', @@ -1584,7 +1645,8 @@ private function mysql_table_has_column_metadata( string $table_schema, string $ array( $table_schema, $table_name ) ); - return false !== $stmt->fetchColumn(); + $this->mysql_table_has_column_metadata_cache[ $cache_key ] = false !== $stmt->fetchColumn(); + return $this->mysql_table_has_column_metadata_cache[ $cache_key ]; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 7d6821980..bf4af3c31 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2752,6 +2752,75 @@ static function ( $row ): string { ); } + /** + * Tests column-reference metadata lookups are cached until table metadata changes. + */ + public function test_wordpress_column_reference_metadata_cache_reuses_lookups_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + + $column_type_queries = 0; + $column_collation_queries = 0; + $table_has_metadata_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$column_type_queries, &$column_collation_queries, &$table_has_metadata_queries ): void { + if ( false !== strpos( $sql, 'SELECT column_type FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_type_queries; + } + if ( false !== strpos( $sql, 'SELECT collation_name FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_collation_queries; + } + if ( false !== strpos( $sql, 'SELECT 1 FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$table_has_metadata_queries; + } + } + ); + + $query = "SELECT post_title FROM wptests_posts, wptests_terms WHERE post_title LIKE '%test%'"; + + $driver->query( $query ); + + $type_queries_after_first = $column_type_queries; + $collation_queries_after_first = $column_collation_queries; + $metadata_queries_after_first = $table_has_metadata_queries; + $this->assertGreaterThan( 0, $type_queries_after_first ); + $this->assertGreaterThan( 0, $collation_queries_after_first ); + $this->assertGreaterThan( 0, $metadata_queries_after_first ); + + $driver->query( $query ); + + $this->assertSame( $type_queries_after_first, $column_type_queries ); + $this->assertSame( $collation_queries_after_first, $column_collation_queries ); + $this->assertSame( $metadata_queries_after_first, $table_has_metadata_queries ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( $query ); + + $this->assertGreaterThan( $type_queries_after_first, $column_type_queries ); + $this->assertGreaterThan( $collation_queries_after_first, $column_collation_queries ); + $this->assertGreaterThan( $metadata_queries_after_first, $table_has_metadata_queries ); + } + /** * Tests WordPress user text predicates and ordering preserve MySQL collation behavior. */ From 6daf53d227787c551457a9043d75652c3daa138d Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 01:41:57 +0000 Subject: [PATCH 097/142] Cache more PostgreSQL metadata lookups --- .../postgresql/class-wp-postgresql-driver.php | 59 +++++- .../tests/WP_PostgreSQL_DB_Tests.php | 194 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 78 +++++++ .../postgresql/class-wp-postgresql-db.php | 26 ++- 4 files changed, 343 insertions(+), 14 deletions(-) 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 index 20b20017a..52bd29036 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -104,6 +104,13 @@ class WP_PostgreSQL_Driver { */ private $mysql_dml_column_metadata_cache = array(); + /** + * DML identity metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_dml_identity_column_metadata_cache = array(); + /** * MySQL column type metadata keyed by backend schema, table, and column. * @@ -125,6 +132,13 @@ class WP_PostgreSQL_Driver { */ private $mysql_table_has_column_metadata_cache = array(); + /** + * Stored MySQL column names keyed by backend schema, table, and requested column. + * + * @var array> + */ + private $mysql_table_column_name_cache = array(); + /** * Cached MySQL upsert conflict targets keyed by table and inserted columns. * @@ -1019,13 +1033,15 @@ private function ensure_mysql_schema_metadata_tables(): void { * Clear all cached MySQL metadata derived from side tables. */ private function clear_mysql_metadata_caches(): void { - $this->mysql_table_schema_introspection_cache = array(); - $this->mysql_dml_column_metadata_cache = array(); - $this->mysql_table_column_type_cache = array(); - $this->mysql_table_column_collation_cache = array(); - $this->mysql_table_has_column_metadata_cache = array(); - $this->mysql_upsert_conflict_target_cache = array(); - $this->mysql_introspection_result_cache = array(); + $this->mysql_table_schema_introspection_cache = array(); + $this->mysql_dml_column_metadata_cache = array(); + $this->mysql_dml_identity_column_metadata_cache = array(); + $this->mysql_table_column_type_cache = array(); + $this->mysql_table_column_collation_cache = array(); + $this->mysql_table_has_column_metadata_cache = array(); + $this->mysql_table_column_name_cache = array(); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); } /** @@ -1038,9 +1054,11 @@ private function clear_mysql_metadata_cache_for_table( string $table_schema, str $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); unset( $this->mysql_dml_column_metadata_cache[ $cache_key ], + $this->mysql_dml_identity_column_metadata_cache[ $cache_key ], $this->mysql_table_column_type_cache[ $cache_key ], $this->mysql_table_column_collation_cache[ $cache_key ], - $this->mysql_table_has_column_metadata_cache[ $cache_key ] + $this->mysql_table_has_column_metadata_cache[ $cache_key ], + $this->mysql_table_column_name_cache[ $cache_key ] ); $this->mysql_upsert_conflict_target_cache = array(); $this->mysql_introspection_result_cache = array(); @@ -4942,6 +4960,11 @@ private function is_explicit_dml_identity_value( string $value_sql ): bool { private function get_dml_identity_column_metadata( string $table_schema, string $table_name ): array { $this->ensure_mysql_schema_metadata_tables(); + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_dml_identity_column_metadata_cache ) ) { + return $this->mysql_dml_identity_column_metadata_cache[ $cache_key ]; + } + $stmt = $this->connection->query( sprintf( 'SELECT @@ -4973,7 +4996,8 @@ private function get_dml_identity_column_metadata( string $table_schema, string array( $table_schema, $table_name ) ); - return $stmt->fetchAll( PDO::FETCH_ASSOC ); + $this->mysql_dml_identity_column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return $this->mysql_dml_identity_column_metadata_cache[ $cache_key ]; } /** @@ -12773,6 +12797,15 @@ private function get_mysql_table_column_name( ): ?string { $this->ensure_mysql_schema_metadata_tables(); + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = $column_name; + if ( + isset( $this->mysql_table_column_name_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_name_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; + } + $stmt = $this->connection->query( sprintf( 'SELECT column_name FROM %s @@ -12787,7 +12820,8 @@ private function get_mysql_table_column_name( $stored_column_name = $stmt->fetchColumn(); if ( false !== $stored_column_name ) { - return (string) $stored_column_name; + $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ] = (string) $stored_column_name; + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; } $lowercase_column_name = strtolower( $column_name ); @@ -12804,7 +12838,10 @@ private function get_mysql_table_column_name( ); $stored_column_name = $stmt->fetchColumn(); - return false === $stored_column_name ? null : (string) $stored_column_name; + $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ] = false === $stored_column_name + ? null + : (string) $stored_column_name; + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 6e1bd0c04..b6bb83267 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -1397,6 +1397,200 @@ public function get_connection(): WP_PostgreSQL_Connection { ); } + /** + * Tests direct PostgreSQL column length fallback metadata is cached until invalidated. + */ + public function test_column_length_fallback_cache_reuses_lookup_until_invalidated(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $length = 50; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $this->queries[] = 'native_columns:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'SELECT data_type, character_maximum_length' ) ) { + $this->queries[] = 'direct_length:' . implode( ':', $params ); + return $this->statement_from_rows( + array( + array( + 'data_type' => 'character varying', + 'character_maximum_length' => $this->length, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_length( int $length ): void { + $this->length = $length; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$first = $db->get_col_length( 'wptests_length_fallback_cache', 'name' ); + +$connection->set_length( 75 ); +$second = $db->get_col_length( 'WPTESTS_LENGTH_FALLBACK_CACHE', 'NAME' ); + +$clear_cache = new ReflectionMethod( WP_PostgreSQL_DB::class, 'clear_postgresql_table_charset_cache' ); +$clear_cache->setAccessible( true ); +$clear_cache->invoke( $db, array( 'wptests_length_fallback_cache' ) ); + +$third = $db->get_col_length( 'wptests_length_fallback_cache', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'first' => $first, + 'second' => $second, + 'third' => $third, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['first'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['second'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 75, + ), + $result['third'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_length_fallback_cache', + 'metadata_exists', + 'native_columns:wptests_length_fallback_cache', + 'direct_length:wptests_length_fallback_cache:name', + 'temp_schema:wptests_length_fallback_cache', + 'native_columns:wptests_length_fallback_cache', + 'direct_length:wptests_length_fallback_cache:name', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_length_fallback_cache`', + 'SHOW FULL COLUMNS FROM `wptests_length_fallback_cache`', + ), + $result['driver_queries'] + ); + } + /** * Tests plain permanent CREATE TABLE invalidates cached missing metadata. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index bf4af3c31..d93d9e4b8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -275,6 +275,41 @@ static function ( $row ): array { ); } + /** + * Tests DML identity metadata is cached and invalidated after metadata changes. + */ + public function test_dml_identity_metadata_cache_reuses_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_cache_identity', 'id', 'wptests_cache_identity_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_cache_identity (id INTEGER PRIMARY KEY, label TEXT NOT NULL)' ); + + $metadata_select_count = 0; + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$metadata_select_count ): void { + if ( false !== strpos( $sql, 'FROM dml_identity_metadata_fixture' ) ) { + ++$metadata_select_count; + } + } + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (1, 'first')" ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (2, 'second')" ) ); + $this->assertSame( 1, $metadata_select_count ); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_cache_identity ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + label varchar(20) NOT NULL DEFAULT '', + PRIMARY KEY (id) + )" + ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (3, 'third')" ) ); + $this->assertSame( 2, $metadata_select_count ); + } + /** * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. */ @@ -2821,6 +2856,49 @@ static function ( string $sql ) use ( &$column_type_queries, &$column_collation_ $this->assertGreaterThan( $metadata_queries_after_first, $table_has_metadata_queries ); } + /** + * Tests qualified column-name metadata lookups are cached until table metadata changes. + */ + public function test_wordpress_column_name_metadata_cache_reuses_lookups_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $column_name_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$column_name_queries ): void { + if ( false !== strpos( $sql, 'SELECT column_name FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_name_queries; + } + } + ); + + $query = 'SELECT p.ID FROM wptests_posts AS p WHERE p.ID > 0'; + + $driver->query( $query ); + $column_name_queries_after_first = $column_name_queries; + $this->assertGreaterThan( 0, $column_name_queries_after_first ); + + $driver->query( $query ); + $this->assertSame( $column_name_queries_after_first, $column_name_queries ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( $query ); + $this->assertGreaterThan( $column_name_queries_after_first, $column_name_queries ); + } + /** * Tests WordPress user text predicates and ordering preserve MySQL collation behavior. */ diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php index 52229f10e..031faf200 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php @@ -47,6 +47,13 @@ class WP_PostgreSQL_DB extends wpdb { */ private $postgresql_column_charset_metadata_cache = array(); + /** + * Request-local column length metadata keyed by normalized table and column names. + * + * @var array + */ + private $postgresql_column_length_cache = array(); + /** * Cached existence state for the PostgreSQL MySQL charset metadata table. * @@ -276,6 +283,13 @@ public function get_col_length( $table, $column ) { } } + 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 @@ -307,6 +321,7 @@ public function get_col_length( $table, $column ) { } if ( ! is_array( $row ) ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = false; return false; } @@ -314,19 +329,22 @@ public function get_col_length( $table, $column ) { $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 ) { - return array( + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = array( 'type' => 'char', 'length' => $length, ); + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; } if ( 'text' === $type ) { - return array( + $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; } @@ -712,7 +730,8 @@ private function clear_postgresql_table_charset_cache( array $tables ): void { $this->table_charset[ $tablekey ], $this->col_meta[ $tablekey ], $this->postgresql_temporary_charset_metadata[ $tablekey ], - $this->postgresql_column_charset_metadata_cache[ $tablekey ] + $this->postgresql_column_charset_metadata_cache[ $tablekey ], + $this->postgresql_column_length_cache[ $tablekey ] ); } } @@ -724,6 +743,7 @@ private function clear_all_postgresql_table_charset_cache(): void { $this->table_charset = array(); $this->col_meta = array(); $this->postgresql_column_charset_metadata_cache = array(); + $this->postgresql_column_length_cache = array(); $this->postgresql_charset_metadata_table_exists = null; } From afcca89d062174e3b1330c86e3e8bcd2a1deefd6 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 02:04:33 +0000 Subject: [PATCH 098/142] Speed up PostgreSQL SELECT translation --- .../postgresql/class-wp-postgresql-driver.php | 103 +++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 110 +++++++++++++++++- 2 files changed, 208 insertions(+), 5 deletions(-) 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 index 52bd29036..718401129 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -153,6 +153,20 @@ class WP_PostgreSQL_Driver { */ private $mysql_introspection_result_cache = array(); + /** + * Most recently tokenized MySQL query. + * + * @var string|null + */ + private $mysql_token_cache_query = null; + + /** + * Token stream for the most recently tokenized MySQL query. + * + * @var WP_MySQL_Token[] + */ + private $mysql_token_cache_tokens = array(); + /** * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. * @@ -745,6 +759,11 @@ private function execute_sql_calc_found_rows_count_query( string $query ): int { * @return string|null PostgreSQL count query, or null when unsupported. */ private function get_sql_calc_found_rows_count_query( string $query ): ?string { + $count_query = $this->get_sql_calc_found_rows_direct_count_query( $query ); + if ( null !== $count_query ) { + return $count_query; + } + $select_query = $this->translate_sql_calc_found_rows_count_select_query( $query ); if ( null === $select_query ) { return null; @@ -758,6 +777,77 @@ private function get_sql_calc_found_rows_count_query( string $query ): ?string { ); } + /** + * Build a direct PostgreSQL count query for simple SQL_CALC_FOUND_ROWS SELECTs. + * + * Non-DISTINCT, non-grouped SELECTs have the same FOUND_ROWS cardinality as + * COUNT(*) over the FROM/WHERE source. DISTINCT, GROUP BY, and HAVING shapes + * stay on the derived-table fallback because their projection determines the + * counted row set. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL count query, or null when the wrapped fallback is required. + */ + private function get_sql_calc_found_rows_direct_count_query( string $query ): ?string { + $query = $this->get_sql_calc_found_rows_count_source_query( $query ); + if ( null === $query ) { + return null; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $projection_start = 1; + if ( WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position ) { + return null; + } + + return sprintf( + 'SELECT COUNT(*) AS %s %s', + $this->connection->quote_identifier( '__wp_pg_found_rows' ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + /** * Translate the unbounded SELECT used for SQL_CALC_FOUND_ROWS accounting. * @@ -10573,8 +10663,17 @@ private function translate_mysql_count_aggregate_projection_alias_query( array $ * @return WP_MySQL_Token[] MySQL lexer token stream. */ private function get_mysql_tokens( string $query ): array { - $lexer = new WP_MySQL_Lexer( $query ); - return $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + if ( $query === $this->mysql_token_cache_query ) { + return $this->mysql_token_cache_tokens; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + $this->mysql_token_cache_query = $query; + $this->mysql_token_cache_tokens = $tokens; + + return $tokens; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index d93d9e4b8..4755e0bd3 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -310,6 +310,27 @@ static function ( string $sql, array $params ) use ( &$metadata_select_count ): $this->assertSame( 2, $metadata_select_count ); } + /** + * Tests MySQL tokenization reuses the most recent query token stream. + */ + public function test_mysql_token_cache_reuses_most_recent_query_tokens(): void { + $driver = $this->create_driver(); + $get_tokens = Closure::bind( + function ( string $query ): array { + return $this->get_mysql_tokens( $query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $first_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $second_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $third_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 2' ); + + $this->assertSame( $first_tokens, $second_tokens ); + $this->assertNotSame( $first_tokens, $third_tokens ); + } + /** * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. */ @@ -2143,7 +2164,7 @@ static function ( $row ) { 'params' => array(), ), array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', 'params' => array(), ), ), @@ -2174,7 +2195,7 @@ public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebrea '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\') AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE wptests_posts.post_type = \'post\'', 'params' => array(), ), ), @@ -3591,7 +3612,7 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v 'params' => array(), ), array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))) AS "__wp_pg_found_rows"', + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', 'params' => array(), ), ), @@ -3697,6 +3718,89 @@ public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_ord $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); } + /** + * Tests simple SQL_CALC_FOUND_ROWS counts use a direct unordered source count. + */ + public function test_simple_sql_calc_found_rows_count_uses_direct_unordered_source_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, 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 ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $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 grouped SQL_CALC_FOUND_ROWS counts keep the cardinality-preserving wrapper. + */ + public function test_grouped_sql_calc_found_rows_count_keeps_cardinality_preserving_wrapper(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (2, \'post\', \'publish\', \'2024-01-02 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'blue\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'green\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (2, \'color\', \'red\')' ); + + $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_posts.post_status = 'publish' + AND wptests_postmeta.meta_key = 'color' + GROUP BY wptests_posts.ID + HAVING COUNT(*) >= 1 + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1" + ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts INNER JOIN wptests_postmeta ON (wptests_posts."ID" = wptests_postmeta.post_id) WHERE wptests_posts.post_status = \'publish\' AND wptests_postmeta.meta_key = \'color\' GROUP BY wptests_posts."ID" HAVING COUNT (*) >= 1) AS "__wp_pg_found_rows"', + $queries[1]['sql'] + ); + $this->assertStringNotContainsString( 'ORDER BY', $queries[1]['sql'] ); + $this->assertStringNotContainsString( 'LIMIT', $queries[1]['sql'] ); + $this->assertStringContainsString( 'FROM (SELECT', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + /** * Tests SQL_CALC_FOUND_ROWS grouped postmeta queries aggregate sort expressions. */ From 39ec9b83b85b4e32659583c2494948ea61cc87a1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 02:12:34 +0000 Subject: [PATCH 099/142] Guard FOUND_ROWS direct counts for aggregates --- .../postgresql/class-wp-postgresql-driver.php | 12 +++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) 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 index 718401129..dec420638 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -780,10 +780,10 @@ private function get_sql_calc_found_rows_count_query( string $query ): ?string { /** * Build a direct PostgreSQL count query for simple SQL_CALC_FOUND_ROWS SELECTs. * - * Non-DISTINCT, non-grouped SELECTs have the same FOUND_ROWS cardinality as - * COUNT(*) over the FROM/WHERE source. DISTINCT, GROUP BY, and HAVING shapes - * stay on the derived-table fallback because their projection determines the - * counted row set. + * Non-DISTINCT, non-grouped, non-aggregate SELECTs have the same FOUND_ROWS + * cardinality as COUNT(*) over the FROM/WHERE source. DISTINCT, aggregate, + * GROUP BY, and HAVING shapes stay on the derived-table fallback because + * their projection determines the counted row set. * * @param string $query MySQL query. * @return string|null PostgreSQL count query, or null when the wrapped fallback is required. @@ -841,6 +841,10 @@ private function get_sql_calc_found_rows_direct_count_query( string $query ): ?s return null; } + if ( $this->contains_mysql_aggregate_call( $tokens, $projection_start, $from_position ) ) { + return null; + } + return sprintf( 'SELECT COUNT(*) AS %s %s', $this->connection->quote_identifier( '__wp_pg_found_rows' ), diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 4755e0bd3..2fb2fb214 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -3761,6 +3761,36 @@ public function test_simple_sql_calc_found_rows_count_uses_direct_unordered_sour $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. */ From a4644a4c6f48636336edb81f240ead6e706f77f6 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 02:36:03 +0000 Subject: [PATCH 100/142] Coerce FOUND_ROWS direct count predicates --- .../postgresql/class-wp-postgresql-driver.php | 54 ++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 79 ++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) 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 index dec420638..5452fb1c3 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -848,7 +848,59 @@ private function get_sql_calc_found_rows_direct_count_query( string $query ): ?s return sprintf( 'SELECT COUNT(*) AS %s %s', $this->connection->quote_identifier( '__wp_pg_found_rows' ), - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + $this->translate_sql_calc_found_rows_direct_count_source_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + + /** + * Translate a direct FOUND_ROWS count source while preserving contextual predicate rewrites. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $from_position FROM token position. + * @param int $statement_end Final statement token position, exclusive. + * @return string PostgreSQL FROM/WHERE SQL. + */ + private function translate_sql_calc_found_rows_direct_count_source_to_postgresql( + array $tokens, + int $from_position, + int $statement_end + ): string { + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $statement_end + ); + if ( null === $where_position ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $where_position ); + if ( null === $scope ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $statement_end, + $scope + ); + if ( ! $where_sql['changed'] ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $from_position, + $statement_end, + array( + array( + 'start' => $where_position + 1, + 'end' => $statement_end, + 'sql' => $where_sql['sql'], + ), + ) ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 2fb2fb214..c444f42ee 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2437,13 +2437,90 @@ public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_p $this->assertSame( '2', $rows[0]->ID ); $this->assertSame( 'match-yololololo', $rows[0]->user_login ); - $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $queries = $driver->get_last_postgresql_queries(); + $sql = $queries[0]['sql']; $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); $this->assertStringContainsString( '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), $sql ); $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); + + $count_sql = $queries[1]['sql']; + $this->assertStringContainsString( + '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), + $count_sql + ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS user searches coerce bad ID terms after author subqueries. + */ + public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_predicate_after_subquery(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + `user_nicename` varchar(50) NOT NULL DEFAULT "", + `display_name` varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_author` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_status` varchar(20) NOT NULL DEFAULT "publish", + `post_type` varchar(20) NOT NULL DEFAULT "post", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `display_name`) ' . + 'VALUES (1, \'admin\', \'admin\', \'Admin\')' + ); + $driver->query( + 'INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `display_name`) ' . + 'VALUES (2, \'match-yololololo\', \'match-yololololo\', \'Match Yololololo\')' + ); + $driver->query( + 'INSERT INTO wptests_posts (`ID`, `post_author`, `post_status`, `post_type`) ' . + 'VALUES (1, 2, \'publish\', \'post\')' + ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_users.ID + FROM wptests_users + WHERE 1=1 + AND wptests_users.ID IN ( + SELECT DISTINCT wptests_posts.post_author + FROM wptests_posts + WHERE wptests_posts.post_status = 'publish' + AND wptests_posts.post_type IN ( 'post', 'page' ) + ) + AND (ID = 'yololololo' + OR user_login LIKE '%yololololo%' + OR user_nicename LIKE '%yololololo%' + OR display_name LIKE '%yololololo%') + ORDER BY display_name ASC + LIMIT 0, 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + + $queries = $driver->get_last_postgresql_queries(); + foreach ( $queries as $query ) { + $sql = $query['sql']; + $this->assertStringContainsString( + '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), + $sql + ); + $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); + $this->assertStringContainsString( "LOWER(user_nicename) LIKE LOWER('%yololololo%')", $sql ); + $this->assertStringContainsString( "LOWER(display_name) LIKE LOWER('%yololololo%')", $sql ); + } } /** From c8c5b3924015abb668e2c60b49a7b479e876bf12 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 02:58:52 +0000 Subject: [PATCH 101/142] Stabilize approved comment ID ordering --- .../postgresql/class-wp-postgresql-driver.php | 32 ++++++++------- .../tests/WP_PostgreSQL_Driver_Tests.php | 39 +++++++++++++++++++ 2 files changed, 56 insertions(+), 15 deletions(-) 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 index 5452fb1c3..5aefcb855 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -6910,9 +6910,8 @@ private function translate_wordpress_term_cache_priming_query( string $query ): private function translate_wordpress_approved_comments_query( string $query ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( - ! isset( $tokens[0], $tokens[1] ) + ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id - || WP_MySQL_Lexer::MULT_OPERATOR !== $tokens[1]->id ) { return null; } @@ -6932,7 +6931,7 @@ private function translate_wordpress_approved_comments_query( string $query ): ? $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 2, $select_end ); $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $select_end ); if ( - 2 !== $from_position + null === $from_position || null === $where_position || null === $order_position || $from_position + 2 !== $where_position @@ -6940,6 +6939,7 @@ private function translate_wordpress_approved_comments_query( string $query ): ? || $order_position + 2 >= $select_end || ! isset( $tokens[ $order_position + 1 ] ) || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || ! $this->is_supported_simple_select_projection( $tokens, 1, $from_position ) ) { return null; } @@ -6978,7 +6978,8 @@ private function translate_wordpress_approved_comments_query( string $query ): ? ); $sql = sprintf( - 'SELECT * FROM %s WHERE %s ORDER BY %s, %s', + 'SELECT %s FROM %s WHERE %s ORDER BY %s, %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 1, $from_position ), $this->translate_mysql_identifier_token_to_postgresql( $table_token ), $where_sql['sql'], $order_sql['sql'], @@ -11011,22 +11012,23 @@ private function is_supported_simple_select_projection( array $tokens, int $star return true; } - for ( $i = $start; $i < $end; $i++ ) { - if ( null === $this->get_mysql_identifier_token_value( $tokens[ $i ] ?? null ) ) { - return false; - } - - ++$i; - if ( $i >= $end ) { - return true; - } + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $projection_ranges ) { + return false; + } - if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { + foreach ( $projection_ranges as $projection_range ) { + $reference = $this->parse_mysql_column_reference( + $tokens, + $projection_range['start'], + $projection_range['end'] + ); + if ( null === $reference || $reference['end'] !== $projection_range['end'] ) { return false; } } - return false; + return true; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index c444f42ee..c7075ddf4 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1086,6 +1086,45 @@ static function ( $row ) { ); } + /** + * 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. */ From 275327ebbe4bab685db09485d0b8b604940da29f Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 03:08:37 +0000 Subject: [PATCH 102/142] Narrow approved comment projection guard --- .../postgresql/class-wp-postgresql-driver.php | 79 +++++++++++++++---- .../tests/WP_PostgreSQL_Driver_Tests.php | 40 ++++++++++ 2 files changed, 105 insertions(+), 14 deletions(-) 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 index 5aefcb855..5fc8497c7 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -6939,14 +6939,17 @@ private function translate_wordpress_approved_comments_query( string $query ): ? || $order_position + 2 >= $select_end || ! isset( $tokens[ $order_position + 1 ] ) || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id - || ! $this->is_supported_simple_select_projection( $tokens, 1, $from_position ) ) { return null; } $table_token = $tokens[ $from_position + 1 ] ?? null; $table_name = $this->get_mysql_identifier_token_value( $table_token ); - if ( null === $table_name || ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) ) { + if ( + null === $table_name + || ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) + || ! $this->is_supported_wordpress_approved_comments_select_projection( $tokens, 1, $from_position, $table_name ) + ) { return null; } @@ -6992,6 +6995,55 @@ private function translate_wordpress_approved_comments_query( string $query ): ? return $sql; } + /** + * Validate the approved-comments SELECT projection. + * + * This translator appends comment_ID to ORDER BY, so it must stay limited + * to row-returning projections. Aggregate/function/expression projections + * can become invalid when the tie-breaker is appended. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @param string $table_name Selected comments table name. + * @return bool Whether the projection is supported. + */ + private function is_supported_wordpress_approved_comments_select_projection( + array $tokens, + int $start, + int $end, + string $table_name + ): bool { + if ( $start + 1 === $end && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start ]->id ) { + return true; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $projection_ranges ) { + return false; + } + + foreach ( $projection_ranges as $projection_range ) { + $reference = $this->parse_mysql_column_reference( + $tokens, + $projection_range['start'], + $projection_range['end'] + ); + if ( + null === $reference + || $reference['end'] !== $projection_range['end'] + || ( + null !== $reference['qualifier'] + && strtolower( $reference['qualifier'] ) !== strtolower( $table_name ) + ) + ) { + return false; + } + } + + return true; + } + /** * Check whether a projection item is alias.*. * @@ -11012,23 +11064,22 @@ private function is_supported_simple_select_projection( array $tokens, int $star return true; } - $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); - if ( null === $projection_ranges ) { - return false; - } + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $i ] ?? null ) ) { + return false; + } - foreach ( $projection_ranges as $projection_range ) { - $reference = $this->parse_mysql_column_reference( - $tokens, - $projection_range['start'], - $projection_range['end'] - ); - if ( null === $reference || $reference['end'] !== $projection_range['end'] ) { + ++$i; + if ( $i >= $end ) { + return true; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { return false; } } - return true; + return false; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index c7075ddf4..4c6a53e02 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1125,6 +1125,46 @@ static function ( $row ) { ); } + /** + * Tests approved comment aggregate lookups are not rewritten with a row tie-breaker. + */ + public function test_simple_select_approved_comments_order_does_not_rewrite_count_projection(): void { + $driver = $this->create_driver(); + + $select = "SELECT COUNT(comment_ID) as c + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_approved_comments_query', + $select + ) + ); + } + + /** + * Tests approved comment projections must belong to the selected comments table. + */ + public function test_simple_select_approved_comments_order_does_not_rewrite_foreign_projection_qualifier(): void { + $driver = $this->create_driver(); + + $select = "SELECT other.comment_ID + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_approved_comments_query', + $select + ) + ); + } + /** * Tests MySQL offset,count LIMIT syntax is translated to PostgreSQL. */ From ddcc8ad7e8e3edfa1cdf5aa4660b8b70e2cd4c60 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 03:36:11 +0000 Subject: [PATCH 103/142] Cache PostgreSQL SELECT translations --- .../postgresql/class-wp-postgresql-driver.php | 312 +++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 126 +++++++ 2 files changed, 388 insertions(+), 50 deletions(-) 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 index 5fc8497c7..988607767 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -34,6 +34,11 @@ class WP_PostgreSQL_Driver { */ private const PDO_FETCH_STYLE_MASK = 0x0f; + /** + * Maximum number of exact MySQL query translations cached per connection. + */ + private const MYSQL_QUERY_TRANSLATION_CACHE_LIMIT = 256; + /** * PostgreSQL server version string. * @@ -153,6 +158,20 @@ class WP_PostgreSQL_Driver { */ private $mysql_introspection_result_cache = array(); + /** + * Cached exact MySQL SELECT translations keyed by query hash. + * + * @var array + */ + private $mysql_select_translation_cache = array(); + + /** + * Cached exact SQL_CALC_FOUND_ROWS count SQL keyed by source query hash. + * + * @var array + */ + private $mysql_sql_calc_found_rows_count_query_cache = array(); + /** * Most recently tokenized MySQL query. * @@ -538,97 +557,238 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); $sql_calc_found_rows_query = $is_sql_calc_found_rows_query ? $query : null; + if ( ! $translated_for_postgresql ) { + if ( $this->is_mysql_select_translation_cacheable_query( $query ) ) { + $select_translation = $this->get_mysql_select_query_translation( $query ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; + } else { + $translated_query = $this->translate_mysql_compatible_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + } + } + + $stmt = $this->connection->query( $query ); + $this->last_postgresql_queries[] = array( + 'sql' => $query, + 'params' => array(), + ); + + $affected_rows = $stmt->rowCount(); + + if ( $stmt->columnCount() > 0 ) { + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( + $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) + ); + if ( 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->last_column_meta = array(); + $this->last_result = $affected_rows; + if ( null !== $replace_return_value ) { + $this->last_result = $replace_return_value; + } + } + + if ( null !== $dml_identity_repair_query ) { + $this->set_last_insert_id_after_dml_success( $dml_identity_repair_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_identity_repair_query, $affected_rows ); + } + + return $this->last_result; + } + + /** + * Check whether a query can use the exact SELECT translation cache. + * + * This intentionally uses a cheap prefix check. Queries with leading comments + * or parenthesized SELECTs keep the uncached fallback path rather than paying + * lexer cost just to decide cacheability. + * + * @param string $query MySQL query. + * @return bool Whether the query is cacheable by exact SQL text. + */ + private function is_mysql_select_translation_cacheable_query( string $query ): bool { + return 1 === preg_match( '/\A\s*SELECT\b/i', $query ); + } + + /** + * Get the PostgreSQL execution SQL for a MySQL SELECT query. + * + * @param string $query MySQL SELECT query. + * @return array{sql: string, translated: bool} PostgreSQL SQL and translation flag. + */ + private function get_mysql_select_query_translation( string $query ): array { + $cached_translation = $this->get_mysql_select_translation_cache_entry( $query ); + if ( null !== $cached_translation ) { + return array( + 'sql' => $cached_translation['sql'], + 'translated' => $cached_translation['translated'], + ); + } + + $translation = $this->translate_mysql_select_query_for_postgresql( $query ); + $this->set_mysql_select_translation_cache_entry( $query, $translation ); + + return $translation; + } + + /** + * Translate a MySQL SELECT query using the existing ordered translator chain. + * + * @param string $query MySQL SELECT query. + * @return array{sql: string, translated: bool} PostgreSQL SQL and translation flag. + */ + private function translate_mysql_select_query_for_postgresql( string $query ): array { $translated_query = $this->translate_information_schema_tables_site_health_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_grouped_having_alias_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_wordpress_available_post_mime_types_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_wordpress_term_cache_priming_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_wordpress_approved_comments_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_simple_mysql_select_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_distinct_order_by_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } $translated_query = $this->translate_sql_calc_found_rows_select_query( $query ); if ( null !== $translated_query ) { - $query = $translated_query; - $translated_for_postgresql = true; + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } - if ( ! $translated_for_postgresql ) { - $translated_query = $this->translate_mysql_compatible_query( $query ); - if ( null !== $translated_query ) { - $query = $translated_query; - } + $translated_query = $this->translate_mysql_compatible_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); } - $stmt = $this->connection->query( $query ); - $this->last_postgresql_queries[] = array( - 'sql' => $query, - 'params' => array(), + return array( + 'sql' => $query, + 'translated' => false, ); + } - $affected_rows = $stmt->rowCount(); - - if ( $stmt->columnCount() > 0 ) { - $this->last_column_meta = $this->normalize_column_meta( $stmt ); - $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( - $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) - ); - if ( 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->last_column_meta = array(); - $this->last_result = $affected_rows; - if ( null !== $replace_return_value ) { - $this->last_result = $replace_return_value; - } + /** + * Get a cached exact SELECT translation. + * + * @param string $query MySQL SELECT query. + * @return array{query: string, sql: string, translated: bool}|null Cached translation. + */ + private function get_mysql_select_translation_cache_entry( string $query ): ?array { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + if ( + ! isset( $this->mysql_select_translation_cache[ $cache_key ] ) + || $this->mysql_select_translation_cache[ $cache_key ]['query'] !== $query + ) { + return null; } - 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->mysql_select_translation_cache[ $cache_key ]; + } - return $this->last_result; + /** + * Store a cached exact SELECT translation. + * + * @param string $query MySQL SELECT query. + * @param array{sql: string, translated: bool} $translation PostgreSQL translation. + */ + private function set_mysql_select_translation_cache_entry( string $query, array $translation ): void { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + $this->mysql_select_translation_cache[ $cache_key ] = array( + 'query' => $query, + 'sql' => $translation['sql'], + 'translated' => $translation['translated'], + ); + + $this->limit_mysql_query_translation_cache( $this->mysql_select_translation_cache ); + } + + /** + * Get the cache key for an exact MySQL query translation. + * + * @param string $query MySQL query. + * @return string Cache key. + */ + private function get_mysql_query_translation_cache_key( string $query ): string { + return sha1( $query ); + } + + /** + * Keep an exact query translation cache bounded. + * + * @param array $cache Cache to trim. + */ + private function limit_mysql_query_translation_cache( array &$cache ): void { + while ( count( $cache ) > self::MYSQL_QUERY_TRANSLATION_CACHE_LIMIT ) { + reset( $cache ); + $first_key = key( $cache ); + if ( null === $first_key ) { + return; + } + unset( $cache[ $first_key ] ); + } } /** @@ -759,8 +919,14 @@ private function execute_sql_calc_found_rows_count_query( string $query ): int { * @return string|null PostgreSQL count query, or null when unsupported. */ private function get_sql_calc_found_rows_count_query( string $query ): ?string { + $cached_count_query = $this->get_mysql_sql_calc_found_rows_count_query_cache_entry( $query ); + if ( null !== $cached_count_query ) { + return $cached_count_query; + } + $count_query = $this->get_sql_calc_found_rows_direct_count_query( $query ); if ( null !== $count_query ) { + $this->set_mysql_sql_calc_found_rows_count_query_cache_entry( $query, $count_query ); return $count_query; } @@ -769,12 +935,48 @@ private function get_sql_calc_found_rows_count_query( string $query ): ?string { return null; } - $alias = $this->connection->quote_identifier( '__wp_pg_found_rows' ); - return sprintf( + $alias = $this->connection->quote_identifier( '__wp_pg_found_rows' ); + $count_query = sprintf( 'SELECT COUNT(*) AS %1$s FROM (%2$s) AS %1$s', $alias, $select_query ); + $this->set_mysql_sql_calc_found_rows_count_query_cache_entry( $query, $count_query ); + return $count_query; + } + + /** + * Get cached PostgreSQL SQL for a SQL_CALC_FOUND_ROWS count query. + * + * @param string $query MySQL SQL_CALC_FOUND_ROWS query. + * @return string|null Cached PostgreSQL count SQL. + */ + private function get_mysql_sql_calc_found_rows_count_query_cache_entry( string $query ): ?string { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + if ( + ! isset( $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ] ) + || $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ]['query'] !== $query + ) { + return null; + } + + return $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ]['sql']; + } + + /** + * Store cached PostgreSQL SQL for a SQL_CALC_FOUND_ROWS count query. + * + * @param string $query MySQL SQL_CALC_FOUND_ROWS query. + * @param string $count_query PostgreSQL count SQL. + */ + private function set_mysql_sql_calc_found_rows_count_query_cache_entry( string $query, string $count_query ): void { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ] = array( + 'query' => $query, + 'sql' => $count_query, + ); + + $this->limit_mysql_query_translation_cache( $this->mysql_sql_calc_found_rows_count_query_cache ); } /** @@ -1188,6 +1390,7 @@ private function clear_mysql_metadata_caches(): void { $this->mysql_table_column_name_cache = array(); $this->mysql_upsert_conflict_target_cache = array(); $this->mysql_introspection_result_cache = array(); + $this->clear_mysql_query_translation_caches(); } /** @@ -1208,6 +1411,7 @@ private function clear_mysql_metadata_cache_for_table( string $table_schema, str ); $this->mysql_upsert_conflict_target_cache = array(); $this->mysql_introspection_result_cache = array(); + $this->clear_mysql_query_translation_caches(); /* * Temporary table creation/drop can change which backend schema an @@ -1227,6 +1431,14 @@ private function get_mysql_metadata_cache_key( string $table_schema, string $tab return $table_schema . "\0" . $table_name; } + /** + * Clear exact query translation caches derived from metadata-sensitive rewrites. + */ + private function clear_mysql_query_translation_caches(): void { + $this->mysql_select_translation_cache = array(); + $this->mysql_sql_calc_found_rows_count_query_cache = array(); + } + /** * Reset metadata side-table state if a query drops the side tables directly. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 4c6a53e02..e2cb04255 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -3076,6 +3076,113 @@ static function ( string $sql ) use ( &$column_name_queries ): void { $this->assertGreaterThan( $column_name_queries_after_first, $column_name_queries ); } + /** + * Tests exact SELECT translations are cached until table metadata changes. + */ + public function test_select_translation_cache_reuses_exact_sql_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (1, 'Hello')" ); + + $query = "SELECT p.ID FROM wptests_posts AS p WHERE p.ID > '0'"; + + $driver->query( $query ); + + $cache = $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ); + $this->assertCount( 1, $cache ); + + $entry = reset( $cache ); + $this->assertIsArray( $entry ); + $this->assertSame( $query, $entry['query'] ); + $this->assertTrue( $entry['translated'] ); + $this->assertStringContainsString( 'p."ID"', $entry['sql'] ); + + $driver->query( $query ); + + $this->assertSame( + $cache, + $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ) + ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $this->assertSame( + array(), + $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ) + ); + } + + /** + * Tests exact SQL_CALC_FOUND_ROWS count SQL is cached until table metadata changes. + */ + public function test_sql_calc_found_rows_count_query_cache_reuses_exact_sql_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (1, 'Hello')" ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (2, 'World')" ); + + $query = "SELECT SQL_CALC_FOUND_ROWS p.ID + FROM wptests_posts AS p + WHERE p.ID > '0' + ORDER BY p.ID ASC + LIMIT 0, 1"; + + $driver->query( $query ); + + $count_cache = $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ); + $this->assertCount( 1, $count_cache ); + + $entry = reset( $count_cache ); + $this->assertIsArray( $entry ); + $this->assertSame( $query, $entry['query'] ); + $this->assertStringStartsWith( 'SELECT COUNT(*) AS "__wp_pg_found_rows"', $entry['sql'] ); + + $postgresql_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $postgresql_queries ); + $count_sql = $postgresql_queries[1]['sql']; + + $driver->query( $query ); + + $this->assertSame( + $count_cache, + $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ) + ); + $this->assertSame( $count_sql, $driver->get_last_postgresql_queries()[1]['sql'] ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $this->assertSame( + array(), + $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ) + ); + } + /** * Tests WordPress user text predicates and ordering preserve MySQL collation behavior. */ @@ -6606,6 +6713,25 @@ function ( string $bound_method_name, string $bound_query ): ?array { 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. * From 03276ceccf22803b1df5c0e2fee16e69fa50e030 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 03:44:27 +0000 Subject: [PATCH 104/142] Restore commented SELECT translation --- .../postgresql/class-wp-postgresql-driver.php | 17 +++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+) 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 index 988607767..88fc8b0b4 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -562,6 +562,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $select_translation = $this->get_mysql_select_query_translation( $query ); $query = $select_translation['sql']; $translated_for_postgresql = $select_translation['translated']; + } elseif ( $this->is_mysql_top_level_select_query( $query ) ) { + $select_translation = $this->translate_mysql_select_query_for_postgresql( $query ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; } else { $translated_query = $this->translate_mysql_compatible_query( $query ); if ( null !== $translated_query ) { @@ -637,6 +641,19 @@ private function get_mysql_select_query_translation( string $query ): array { return $translation; } + /** + * Check whether a query is a top-level MySQL SELECT after lexer normalization. + * + * @param string $query MySQL query. + * @return bool Whether the lexer sees a complete SELECT statement. + */ + private function is_mysql_top_level_select_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id + && null !== $this->get_mysql_statement_end_position( $tokens, 1 ); + } + /** * Translate a MySQL SELECT query using the existing ordered translator chain. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index e2cb04255..bc70eac52 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -2251,6 +2251,44 @@ static function ( $row ) { ); } + /** + * 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. */ @@ -3886,6 +3924,43 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); } + /** + * Tests leading-comment SQL_CALC_FOUND_ROWS SELECTs still use FOUND_ROWS accounting. + */ + public function test_leading_comment_sql_calc_found_rows_select_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (2, 'post', 'publish', '2024-01-01 00:00:00')" ); + + $select = "/* cache gate */ SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" 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(), + ), + array( + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', + '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. */ From 87dfcbfe4d10d9fb6b4416386dc64617b8da54b2 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 04:13:21 +0000 Subject: [PATCH 105/142] Skip WordPress npm setup in PostgreSQL CI --- .github/workflows/wp-tests-phpunit-run.js | 246 +++++++++++++++------- .github/workflows/wp-tests-phpunit.yml | 14 +- wp-setup.sh | 11 +- 3 files changed, 189 insertions(+), 82 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index b9615c9f1..08234b178 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -272,12 +272,9 @@ echo $extension . " is loaded.\\n"; } function verifyContainerPhpExtension( service, verifier ) { - const runArgs = 'cli' === service ? '--rm --entrypoint php cli' : '--rm php php'; + const runArgs = 'cli' === service ? [ '--rm', '--entrypoint', 'php', 'cli' ] : [ '--rm', 'php', 'php' ]; const containerPath = `/var/www/${ path.basename( verifier ) }`; - execSync( - `cd wordpress && node tools/local-env/scripts/docker.js run ${ runArgs } ${ containerPath }`, - { stdio: 'inherit' } - ); + runWordPressDockerCompose( [ 'run', ...runArgs, containerPath ] ); } function runPhpUnit() { @@ -302,10 +299,7 @@ function runPhpUnit() { ], { cwd: path.join( repositoryRoot, 'wordpress' ), - env: { - ...process.env, - COMPOSE_IGNORE_ORPHANS: 'true', - }, + env: getWordPressDockerComposeEnv(), stdio: 'inherit', } ); @@ -352,18 +346,127 @@ function ensureWordPressTestEnvironment() { } function ensurePostgreSqlWordPressTestEnvironment() { - execSync( - 'cd wordpress && npm run env:start && npm run env:install', + runWordPressDockerCompose( [ 'up', '-d', 'wordpress-develop' ] ); + runWordPressDockerCompose( [ 'run', '-T', 'php', 'composer', 'update', '-W' ] ); + writePostgreSqlWpConfig(); + writePostgreSqlWpTestsConfig(); + installPostgreSqlWpImporter(); +} + +function runWordPressDockerCompose( args ) { + execFileSync( + 'docker', + [ + 'compose', + ...getWordPressDockerComposeArgs(), + ...args, + ], + { + cwd: path.join( repositoryRoot, 'wordpress' ), + env: getWordPressDockerComposeEnv(), + stdio: 'inherit', + } + ); +} + +function getWordPressDockerComposeEnv() { + 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 writePostgreSqlWpConfig() { + const wordpressRoot = path.join( repositoryRoot, 'wordpress' ); + let config = fs.readFileSync( path.join( wordpressRoot, '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', " + getPostgreSqlRawConstantValue( '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', " + getPostgreSqlRawConstantValue( 'LOCAL_WP_DEBUG_LOG', 'true' ) + " );", + "define( 'WP_DEBUG_DISPLAY', " + getPostgreSqlRawConstantValue( 'LOCAL_WP_DEBUG_DISPLAY', 'true' ) + " );", + "define( 'SCRIPT_DEBUG', " + getPostgreSqlRawConstantValue( 'LOCAL_SCRIPT_DEBUG', 'true' ) + " );", + "define( 'WP_ENVIRONMENT_TYPE', " + quotePostgreSqlPhpString( getPostgreSqlEnvValue( 'LOCAL_WP_ENVIRONMENT_TYPE', 'local' ) ) + " );", + "define( 'WP_DEVELOPMENT_MODE', " + quotePostgreSqlPhpString( getPostgreSqlEnvValue( 'LOCAL_WP_DEVELOPMENT_MODE', 'core' ) ) + " );", + ].join( '\n' ) + ); + + fs.rmSync( path.join( wordpressRoot, 'src', 'wp-config.php' ), { force: true } ); + fs.writeFileSync( path.join( wordpressRoot, 'wp-config.php' ), config ); +} + +function writePostgreSqlWpTestsConfig() { + const wordpressRoot = path.join( repositoryRoot, 'wordpress' ); + const testConfig = fs.readFileSync( path.join( wordpressRoot, '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', " + quotePostgreSqlPhpString( getPostgreSqlEnvValue( '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( path.join( wordpressRoot, 'wp-tests-config.php' ), testConfig ); +} + +function installPostgreSqlWpImporter() { + const wordpressRoot = path.join( repositoryRoot, 'wordpress' ); + const testPluginDirectory = path.join( 'tests', 'phpunit', 'data', 'plugins', 'wordpress-importer' ); + if ( fs.existsSync( path.join( wordpressRoot, testPluginDirectory, 'wordpress-importer.php' ) ) ) { + return; + } + + fs.rmSync( path.join( wordpressRoot, testPluginDirectory ), { recursive: true, force: true } ); + execFileSync( + 'git', + [ + 'clone', + 'https://github.com/WordPress/wordpress-importer.git', + testPluginDirectory, + '--depth=1', + ], { - env: { - ...process.env, - COMPOSE_IGNORE_ORPHANS: 'true', - }, + cwd: wordpressRoot, stdio: 'inherit', } ); } +function getPostgreSqlEnvValue( name, defaultValue ) { + return process.env[ name ] || defaultValue; +} + +function getPostgreSqlRawConstantValue( name, defaultValue ) { + const value = getPostgreSqlEnvValue( 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 quotePostgreSqlPhpString( value ) { + return "'" + String( value ).replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) + "'"; +} + function ensureGeneratedBackendFiles() { if ( 'mysql' === backend ) { return; @@ -390,6 +493,7 @@ function runWordPressSetup() { env: { ...process.env, WP_TEST_DB_BACKEND: backend, + ...( 'postgresql' === backend ? { WP_TEST_SKIP_WORDPRESS_NPM: '1' } : {} ), }, stdio: 'inherit', } ); @@ -622,80 +726,66 @@ function removeStaleTestOutput( file ) { } function readJunitTestcases( junitOutputFile ) { - const parserPath = require.resolve( 'fast-xml-parser', { - paths: [ - path.join( repositoryRoot, 'wordpress', 'node_modules' ), - repositoryRoot, - ], - } ); - const { XMLParser } = require( parserPath ); - const parser = new XMLParser( { - attributeNamePrefix: '', - ignoreAttributes: false, - isArray: name => [ - 'testsuite', - 'testcase', - 'error', - 'failure', - 'skipped', - 'incomplete', - 'risky', - 'warning', - ].includes( name ), - } ); const junitXml = fs.readFileSync( junitOutputFile, 'utf8' ); - const parsed = parser.parse( junitXml ); const testcases = []; - collectTestcases( parsed, testcases, false ); - return testcases.map( normalizeTestcase ); + const testcasePattern = /]*)\/>|]*)>([\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 collectTestcases( node, testcases, isTestcase ) { - if ( Array.isArray( node ) ) { - node.forEach( child => collectTestcases( child, testcases, isTestcase ) ); - return; - } +function parseXmlAttributes( attributesXml ) { + const attributes = {}; + const attributePattern = /([A-Za-z_:][A-Za-z0-9_.:-]*)="([^"]*)"/g; + let match; - if ( ! node || typeof node !== 'object' ) { - return; + while ( ( match = attributePattern.exec( attributesXml ) ) !== null ) { + attributes[ match[1] ] = decodeXmlEntities( match[2] ); } - if ( isTestcase ) { - testcases.push( node ); - return; - } - - if ( node.testcase ) { - collectTestcases( node.testcase, testcases, true ); - } - - if ( node.testsuite ) { - collectTestcases( node.testsuite, testcases, false ); - } - - if ( node.testsuites ) { - collectTestcases( node.testsuites, testcases, false ); - } + return attributes; } -function normalizeTestcase( testcase ) { - const className = testcase.class || ''; - const testName = testcase.name || ''; - const fullName = className ? `${ className }::${ testName }` : testName; - - return { - name: fullName, - hasError: hasChild( testcase, 'error' ), - hasFailure: hasChild( testcase, 'failure' ), - hasSkipped: hasChild( testcase, 'skipped' ), - hasIncomplete: hasChild( testcase, 'incomplete' ), - hasRisky: hasChild( testcase, 'risky' ), - hasWarning: hasChild( testcase, 'warning' ), - }; +function hasJunitChild( body, childName ) { + return new RegExp( `<${ childName }(?:[\\s>/])` ).test( body ); } -function hasChild( testcase, childName ) { - return Array.isArray( testcase[ childName ] ) && testcase[ childName ].length > 0; +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 ) { diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 6b3dd813d..d502d1d56 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -86,7 +86,19 @@ jobs: - name: Stop Docker containers if: always() - run: composer run wp-test-clean + 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 diff --git a/wp-setup.sh b/wp-setup.sh index dbb54d2e6..bc98e8f30 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -10,6 +10,7 @@ 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}" DIR="$(cd "$(dirname "$0")" && pwd)" WP_DIR="$DIR/wordpress" @@ -470,6 +471,10 @@ NODE fi # 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 "Skipping WordPress npm install and JavaScript build for PostgreSQL PHP tests..." +else + echo "Installing dependencies..." + npm --prefix "$WP_DIR" install + npm --prefix "$WP_DIR" run build:dev +fi From 0be0be12eef0382abe44852fb3f08fc555282fa7 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 04:48:34 +0000 Subject: [PATCH 106/142] Hydrate WordPress assets for PostgreSQL tests --- .github/workflows/wp-tests-phpunit-run.js | 17 ++++++++ wp-setup.sh | 47 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 08234b178..1d3b50da5 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -686,6 +686,17 @@ function validateGeneratedBackendFiles() { `core \${ installCommand }`, 'install.js does not call the MySQL-backed core install command for PostgreSQL' ); + for ( const [ assetPath, description ] of [ + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-includes', 'assets', 'script-loader-packages.php' ), 'WordPress package script-loader assets' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-includes', 'assets', 'script-modules-packages.php' ), 'WordPress package script-module assets' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-includes', 'js', 'dist', 'i18n.min.js' ), 'WordPress package JavaScript builds' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-includes', 'css', 'dist', 'block-library', 'style.min.css' ), 'WordPress package stylesheet builds' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-admin', 'js', 'common.min.js' ), 'WordPress admin JavaScript builds' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-admin', 'css', 'common-rtl.min.css' ), 'WordPress admin RTL stylesheet builds' ], + [ path.join( repositoryRoot, 'wordpress', 'src', 'wp-includes', 'blocks', 'file', 'view.js' ), 'WordPress block view scripts' ], + ] ) { + assertFileExists( assetPath, description ); + } } } @@ -711,6 +722,12 @@ function assertFileDoesNotContain( file, unexpected, description ) { } } +function assertFileExists( file, description ) { + if ( ! fs.existsSync( file ) ) { + throw new Error( `Expected generated ${ description } to exist: ${ file }.` ); + } +} + function readGeneratedFile( file ) { if ( ! fs.existsSync( file ) ) { throw new Error( `Expected generated file to exist: ${ file }.` ); diff --git a/wp-setup.sh b/wp-setup.sh index bc98e8f30..cc8e28bc8 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -11,6 +11,7 @@ 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="$(cd "$(dirname "$0")" && pwd)" WP_DIR="$DIR/wordpress" @@ -470,9 +471,55 @@ 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. if [ "$WP_TEST_DB_BACKEND" = "postgresql" ] && [ "$WP_TEST_SKIP_WORDPRESS_NPM" = "1" ]; then echo "Skipping WordPress npm install and JavaScript build for PostgreSQL PHP tests..." + install_wordpress_release_assets else echo "Installing dependencies..." npm --prefix "$WP_DIR" install From 8ad115b95dd66f2a925353718c589f307625c65f Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 05:37:03 +0000 Subject: [PATCH 107/142] Reduce PostgreSQL WordPress test runtime --- .../postgresql/class-wp-postgresql-driver.php | 338 ++++++++++++++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 96 +++-- 2 files changed, 383 insertions(+), 51 deletions(-) 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 index 88fc8b0b4..7a33f6c61 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -34,6 +34,11 @@ class WP_PostgreSQL_Driver { */ 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. */ @@ -74,6 +79,30 @@ class WP_PostgreSQL_Driver { */ 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. * @@ -556,9 +585,17 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); $sql_calc_found_rows_query = $is_sql_calc_found_rows_query ? $query : null; + $sql_calc_found_rows_window = false; if ( ! $translated_for_postgresql ) { - if ( $this->is_mysql_select_translation_cacheable_query( $query ) ) { + $translated_query = null !== $sql_calc_found_rows_query && $this->is_sql_calc_found_rows_window_fetch_mode( $fetch_mode ) + ? $this->translate_sql_calc_found_rows_window_select_query( $query ) + : null; + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + $sql_calc_found_rows_window = true; + } elseif ( $this->is_mysql_select_translation_cacheable_query( $query ) ) { $select_translation = $this->get_mysql_select_query_translation( $query ); $query = $select_translation['sql']; $translated_for_postgresql = $select_translation['translated']; @@ -582,17 +619,24 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $affected_rows = $stmt->rowCount(); - if ( $stmt->columnCount() > 0 ) { - $this->last_column_meta = $this->normalize_column_meta( $stmt ); - $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( + $column_count = $stmt->columnCount(); + if ( $column_count > 0 ) { + $this->set_lazy_last_column_meta( $stmt, $column_count ); + $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) ); - if ( null !== $sql_calc_found_rows_query ) { + 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->last_column_meta = array(); - $this->last_result = $affected_rows; + $this->clear_last_column_meta(); + $this->last_result = $affected_rows; if ( null !== $replace_return_value ) { $this->last_result = $replace_return_value; } @@ -996,6 +1040,173 @@ private function set_mysql_sql_calc_found_rows_count_query_cache_entry( string $ $this->limit_mysql_query_translation_cache( $this->mysql_sql_calc_found_rows_count_query_cache ); } + /** + * Check whether a fetch mode can hide the internal FOUND_ROWS window column. + * + * @param int $fetch_mode PDO fetch mode. + * @return bool Whether the hidden window column can be removed safely. + */ + private function is_sql_calc_found_rows_window_fetch_mode( $fetch_mode ): bool { + $fetch_style = (int) $fetch_mode & self::PDO_FETCH_STYLE_MASK; + return in_array( $fetch_style, array( PDO::FETCH_OBJ, PDO::FETCH_ASSOC ), true ); + } + + /** + * Translate a simple SQL_CALC_FOUND_ROWS SELECT using one PostgreSQL query. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query carrying a hidden FOUND_ROWS value, or null. + */ + private function translate_sql_calc_found_rows_window_select_query( string $query ): ?string { + if ( false !== stripos( $query, self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ) ) { + return null; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $projection_start = 2; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::LIMIT_SYMBOL, + $projection_start, + $statement_end + ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $select_end + ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + if ( $this->contains_mysql_aggregate_call( $tokens, $projection_start, $from_position ) ) { + return null; + } + + $replacements = $this->get_mysql_select_statement_contextual_replacements( + $tokens, + $projection_start, + $select_end + ) ?? array(); + + $sql = sprintf( + 'SELECT %s, COUNT(*) OVER() AS %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $from_position ), + $this->connection->quote_identifier( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ), + $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $from_position, + $select_end, + $replacements + ) + ); + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Extract and remove the hidden FOUND_ROWS window column from result rows. + * + * @param mixed $rows Result rows. + * @return int|null FOUND_ROWS value, or null when the fallback count is needed. + */ + private function extract_sql_calc_found_rows_window_result( &$rows ): ?int { + if ( ! is_array( $rows ) || empty( $rows ) ) { + return null; + } + + $found_rows = null; + $column = self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN; + foreach ( $rows as &$row ) { + if ( is_object( $row ) ) { + if ( ! property_exists( $row, $column ) ) { + return null; + } + + $found_rows = (int) $row->{$column}; + unset( $row->{$column} ); + continue; + } + + if ( ! is_array( $row ) || ! array_key_exists( $column, $row ) ) { + return null; + } + + $found_rows = (int) $row[ $column ]; + unset( $row[ $column ] ); + } + unset( $row ); + + return $found_rows; + } + + /** + * Remove hidden FOUND_ROWS metadata before exposing column metadata to wpdb. + */ + private function remove_sql_calc_found_rows_window_column_meta(): void { + if ( null !== $this->last_column_meta_statement ) { + $this->last_column_meta_excluded_names[ self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ] = true; + $this->last_column_count = max( 0, $this->last_column_count - 1 ); + return; + } + + foreach ( $this->last_column_meta as $index => $column_meta ) { + if ( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN !== ( $column_meta['name'] ?? '' ) ) { + continue; + } + + unset( $this->last_column_meta[ $index ] ); + $this->last_column_meta = array_values( $this->last_column_meta ); + return; + } + } + /** * Build a direct PostgreSQL count query for simple SQL_CALC_FOUND_ROWS SELECTs. * @@ -4111,6 +4322,10 @@ public function get_last_return_value() { * @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 ); } @@ -4120,6 +4335,7 @@ public function get_last_column_count(): int { * @return array */ public function get_last_column_meta(): array { + $this->materialize_last_column_meta(); return $this->last_column_meta; } @@ -4160,10 +4376,53 @@ public function rollback(): void { * Reset per-query state. */ private function reset_query_state(): void { - $this->last_result = null; - $this->last_column_meta = array(); - $this->last_mysql_query = null; - $this->last_postgresql_queries = array(); + $this->last_result = null; + $this->last_column_meta = array(); + $this->last_column_count = 0; + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + $this->last_mysql_query = null; + $this->last_postgresql_queries = array(); + } + + /** + * Clear column metadata for a non-result statement. + */ + private function clear_last_column_meta(): void { + $this->last_column_meta = array(); + $this->last_column_count = 0; + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + } + + /** + * Store a statement for lazy column metadata normalization. + * + * @param PDOStatement $stmt Statement with result columns. + * @param int $column_count Number of result columns. + */ + private function set_lazy_last_column_meta( PDOStatement $stmt, int $column_count ): void { + $this->last_column_meta = array(); + $this->last_column_count = $column_count; + $this->last_column_meta_statement = $stmt; + $this->last_column_meta_excluded_names = array(); + } + + /** + * Normalize deferred column metadata when a caller actually needs it. + */ + private function materialize_last_column_meta(): void { + if ( null === $this->last_column_meta_statement ) { + return; + } + + $this->last_column_meta = $this->normalize_column_meta( + $this->last_column_meta_statement, + $this->last_column_meta_excluded_names + ); + $this->last_column_count = count( $this->last_column_meta ); + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); } /** @@ -11379,6 +11638,40 @@ private function translate_mysql_select_statement_with_integer_string_coercion( int $statement_end, bool $require_contextual_change ): ?string { + $replacements = $this->get_mysql_select_statement_contextual_replacements( + $tokens, + $projection_start, + $statement_end + ); + if ( null === $replacements ) { + return null; + } + + if ( $require_contextual_change && empty( $replacements ) ) { + return null; + } + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $projection_start, + $statement_end, + $replacements + ); + } + + /** + * Get metadata-backed replacements for SELECT WHERE and ORDER BY clauses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First token after SELECT modifiers to render. + * @param int $statement_end Final statement token position, exclusive. + * @return array[]|null Replacement ranges, or null when contextual rewriting is unavailable. + */ + private function get_mysql_select_statement_contextual_replacements( + array $tokens, + int $projection_start, + int $statement_end + ): ?array { $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, @@ -11504,16 +11797,7 @@ private function translate_mysql_select_statement_with_integer_string_coercion( } } - 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 - ); + return $replacements; } /** @@ -16475,17 +16759,23 @@ private function read_server_version(): string { /** * Normalize PDO column metadata into the MySQLi-shaped fields wpdb expects. * - * @param PDOStatement $stmt The statement to inspect. + * @param PDOStatement $stmt The statement to inspect. + * @param array $excluded_names Column names hidden from callers. * @return array */ - private function normalize_column_meta( PDOStatement $stmt ): array { + private function normalize_column_meta( PDOStatement $stmt, array $excluded_names = array() ): array { $meta = array(); for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { $column_meta = $stmt->getColumnMeta( $i ); if ( ! is_array( $column_meta ) ) { $column_meta = array(); } - $meta[] = $this->normalize_single_column_meta( $column_meta ); + $normalized_column_meta = $this->normalize_single_column_meta( $column_meta ); + if ( isset( $excluded_names[ $normalized_column_meta['name'] ] ) ) { + continue; + } + + $meta[] = $normalized_column_meta; } return $meta; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index bc70eac52..171cb2360 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -48,6 +48,25 @@ public function test_query_returns_rows_and_metadata(): void { $this->assertArrayHasKey( 'mysqli:charsetnr', $column_meta[0] ); } + /** + * Tests result column metadata is normalized only when requested. + */ + public function test_query_defers_column_metadata_until_requested(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT 1 AS id, 'ok' AS value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 2, $driver->get_last_column_count() ); + $this->assertSame( array(), $this->get_driver_private_property( $driver, 'last_column_meta' ) ); + $this->assertInstanceOf( PDOStatement::class, $this->get_driver_private_property( $driver, 'last_column_meta_statement' ) ); + + $column_meta = $driver->get_last_column_meta(); + $this->assertCount( 2, $column_meta ); + $this->assertSame( 'id', $column_meta[0]['name'] ); + $this->assertNull( $this->get_driver_private_property( $driver, 'last_column_meta_statement' ) ); + } + /** * Tests fetched PostgreSQL-safe text decodes to MySQL NUL bytes. */ @@ -2239,11 +2258,7 @@ static function ( $row ) { $this->assertSame( array( array( - 'sql' => 'SELECT 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, wptests_posts."ID" DESC LIMIT 5 OFFSET 0', - 'params' => array(), - ), - array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', + '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(), ), ), @@ -2308,11 +2323,7 @@ public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebrea $this->assertSame( array( array( - 'sql' => 'SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' ORDER BY wptests_posts.post_date ASC LIMIT 5 OFFSET 0', - 'params' => array(), - ), - array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE wptests_posts.post_type = \'post\'', + '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(), ), ), @@ -2562,11 +2573,9 @@ public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_p $sql ); $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); - - $count_sql = $queries[1]['sql']; $this->assertStringContainsString( - '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), - $count_sql + 'COUNT(*) OVER() AS "__wp_pg_found_rows"', + $sql ); } @@ -3183,7 +3192,7 @@ public function test_sql_calc_found_rows_count_query_cache_reuses_exact_sql_unti FROM wptests_posts AS p WHERE p.ID > '0' ORDER BY p.ID ASC - LIMIT 0, 1"; + LIMIT 10, 1"; $driver->query( $query ); @@ -3909,11 +3918,7 @@ public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): v $this->assertSame( array( array( - 'sql' => 'SELECT 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, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', - 'params' => array(), - ), - array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', + '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(), ), ), @@ -3946,11 +3951,7 @@ public function test_leading_comment_sql_calc_found_rows_select_is_translated_to $this->assertSame( array( array( - 'sql' => 'SELECT 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, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', - 'params' => array(), - ), - array( - 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\')))', + '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(), ), ), @@ -4057,9 +4058,9 @@ public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_ord } /** - * Tests simple SQL_CALC_FOUND_ROWS counts use a direct unordered source count. + * Tests simple SQL_CALC_FOUND_ROWS counts use the paged result when possible. */ - public function test_simple_sql_calc_found_rows_count_uses_direct_unordered_source_count(): void { + 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)' ); @@ -4084,9 +4085,50 @@ public function test_simple_sql_calc_found_rows_count_uses_direct_unordered_sour $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 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'] From 85f40989ecaa33df6fbe77557a75f226d5073dc3 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 05:46:07 +0000 Subject: [PATCH 108/142] Reject grouped SQL_CALC window fetch modes --- .../postgresql/class-wp-postgresql-driver.php | 3 +- .../tests/WP_PostgreSQL_Driver_Tests.php | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) 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 index 7a33f6c61..16a7c5267 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -1047,8 +1047,7 @@ private function set_mysql_sql_calc_found_rows_count_query_cache_entry( string $ * @return bool Whether the hidden window column can be removed safely. */ private function is_sql_calc_found_rows_window_fetch_mode( $fetch_mode ): bool { - $fetch_style = (int) $fetch_mode & self::PDO_FETCH_STYLE_MASK; - return in_array( $fetch_style, array( PDO::FETCH_OBJ, PDO::FETCH_ASSOC ), true ); + return in_array( (int) $fetch_mode, array( PDO::FETCH_OBJ, PDO::FETCH_ASSOC ), true ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 171cb2360..2bd7d1c79 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -4098,6 +4098,74 @@ public function test_simple_sql_calc_found_rows_count_uses_window_count_for_non_ $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. */ From b82f4bcce95d52ff0c9e06474ea4a4b86c5f66f1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 13:20:55 +0000 Subject: [PATCH 109/142] Improve PostgreSQL compatibility for popular plugins --- .../postgresql/class-wp-postgresql-driver.php | 1267 ++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 567 ++++++++ 2 files changed, 1756 insertions(+), 78 deletions(-) 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 index 16a7c5267..bd5b51d8a 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -540,6 +540,18 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_postgresql_statements( array( $translated_query ) ); } + $translated_query = $this->translate_mysql_left_join_orphan_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_mysql_single_target_join_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_delete_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -577,6 +589,13 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $translated_for_postgresql = true; } + $insert_select_query = $this->translate_simple_mysql_insert_select_query( $query ); + if ( null !== $insert_select_query ) { + $query = $insert_select_query['sql']; + $dml_identity_repair_query = $insert_select_query; + $translated_for_postgresql = true; + } + $translated_query = $this->translate_simple_mysql_update_query( $query ); if ( null !== $translated_query ) { $query = $translated_query; @@ -1919,6 +1938,15 @@ private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { return; } + if ( 'change_columns' === $metadata['operation'] ) { + foreach ( $metadata['columns'] as $column_metadata ) { + $column_metadata['operation'] = 'change_column'; + $column_metadata['table'] = $table_name; + $this->apply_mysql_dbdelta_alter_metadata( $column_metadata ); + } + return; + } + if ( 'add_index' === $metadata['operation'] ) { $this->delete_mysql_index_metadata( $table_schema, $table_name, $metadata['index']['name'] ); $this->insert_mysql_index_metadata( $table_schema, $table_name, $metadata['index'] ); @@ -2336,6 +2364,11 @@ private function translate_mysql_dbdelta_alter_table_query( string $query ): ?ar ); } + $modify_query = $this->translate_mysql_dbdelta_modify_column_alter_query( $table_name, $clause ); + if ( null !== $modify_query ) { + return $modify_query; + } + if ( preg_match( '/^ADD\s+COLUMN\s+(?P.+)$/is', $clause, $add_column_matches ) ) { $column = $this->translate_mysql_column_definition_fragment( $add_column_matches['definition'] ); if ( null === $column ) { @@ -2402,6 +2435,110 @@ private function translate_mysql_dbdelta_alter_table_query( string $query ): ?ar return null; } + /** + * Translate ALTER TABLE MODIFY COLUMN clauses. + * + * Action Scheduler emits comma-separated MODIFY COLUMN clauses for datetime + * null/default adjustments. Treat each one like CHANGE COLUMN without a + * rename and keep unsupported ALTER fragments visible by returning null. + * + * @param string $table_name Table name. + * @param string $clause ALTER TABLE clause fragment. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_modify_column_alter_query( string $table_name, string $clause ): ?array { + $tokens = $this->get_mysql_tokens( $clause ); + $statement_end = $this->get_mysql_statement_end_position( $tokens, 0 ); + if ( null === $statement_end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, 0, $statement_end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $statements = array(); + $columns = array(); + foreach ( $ranges as $range ) { + $position = $range['start']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::MODIFY_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( $position >= $range['end'] ) { + return null; + } + + $definition = $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $range['end'] ); + $column = $this->translate_mysql_column_definition_fragment( $definition ); + if ( null === $column ) { + return null; + } + + $column_name = $column['metadata']['name']; + $column_type = $this->get_translated_column_type_from_definition_line( $column['sql'] ); + $preserve_existing_identity = $this->should_preserve_existing_identity_integer_column_change( + 'public', + $table_name, + $column_name, + $column['metadata'] + ); + if ( '' !== $column_type && ! $preserve_existing_identity ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s TYPE %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $column_type + ); + } + + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s NOT NULL', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + 'NO' === ( $column['metadata']['nullable'] ?? 'YES' ) ? 'SET' : 'DROP' + ); + + $default_sql = $this->get_translated_column_default_from_definition_line( $column['sql'] ); + if ( ! $preserve_existing_identity ) { + if ( null !== $default_sql ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $default_sql + ); + } else { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ); + } + } + + $columns[] = array( + 'old_column' => $column_name, + 'column' => $column['metadata'], + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'change_columns', + 'table' => $table_name, + 'columns' => $columns, + ), + ); + } + /** * Check whether an AUTO_INCREMENT CHANGE COLUMN should leave PostgreSQL identity DDL untouched. * @@ -2610,6 +2747,25 @@ private function get_temporary_drop_table_schema_name(): string { return 'pg_temp'; } + /** + * Extract original bytes for a bounded MySQL token range. + * + * @param string $query Original MySQL query fragment. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return string Original query bytes for the token range. + */ + private function get_mysql_token_range_bytes( string $query, array $tokens, int $start, int $end ): string { + if ( $start >= $end || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) ) { + return ''; + } + + $range_start = $tokens[ $start ]->start; + $range_end = $tokens[ $end - 1 ]->start + $tokens[ $end - 1 ]->length; + return substr( $query, $range_start, $range_end - $range_start ); + } + /** * Translate a MySQL column definition fragment via the CREATE TABLE translator. * @@ -4526,6 +4682,159 @@ private function translate_wordpress_expired_transients_delete_query( string $qu ); } + /** + * Translate MySQL single-target orphan cleanup DELETE statements. + * + * WooCommerce emits DELETE alias FROM target alias LEFT JOIN related alias + * ... WHERE related.id IS NULL to purge orphaned metadata. PostgreSQL does + * not support MySQL's DELETE target list, and rewriting the LEFT JOIN to a + * PostgreSQL USING join would change the anti-join semantics. Keep this path + * constrained to the exact single LEFT JOIN null-rejection shape. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_mysql_left_join_orphan_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3], $tokens[4], $tokens[5], $tokens[6], $tokens[7], $tokens[8], $tokens[9] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[5]->id + || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[6]->id + || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[9]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 10 ); + if ( null === $statement_end ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 10, $statement_end ); + if ( null === $where_position || 10 >= $where_position || $where_position + 1 >= $statement_end ) { + return null; + } + + $delete_alias = $this->get_mysql_identifier_token_value( $tokens[1] ); + $target_table = $this->get_mysql_identifier_token_value( $tokens[3] ); + $target_alias = $this->get_mysql_identifier_token_value( $tokens[4] ); + $joined_table = $this->get_mysql_identifier_token_value( $tokens[7] ); + $joined_alias = $this->get_mysql_identifier_token_value( $tokens[8] ); + if ( + null === $delete_alias + || null === $target_table + || null === $target_alias + || null === $joined_table + || null === $joined_alias + || strtolower( $delete_alias ) !== strtolower( $target_alias ) + ) { + return null; + } + + if ( ! $this->is_mysql_null_rejected_join_alias_predicate( $tokens, $where_position + 1, $statement_end, $joined_alias ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s AS %s WHERE NOT EXISTS (SELECT 1 FROM %s AS %s WHERE %s)', + $this->connection->quote_identifier( $target_table ), + $this->translate_mysql_identifier_value_to_postgresql( $target_alias ), + $this->connection->quote_identifier( $joined_table ), + $this->translate_mysql_identifier_value_to_postgresql( $joined_alias ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 10, $where_position ) + ); + } + + /** + * Translate MySQL single-target joined DELETE statements. + * + * bbPress emits DELETE alias FROM target AS alias LEFT JOIN ... WHERE ... + * repair queries. PostgreSQL has no MySQL-style DELETE target list, and a + * direct DELETE USING rewrite would collapse LEFT JOIN semantics. Select the + * target physical rows through an equivalent joined subquery instead. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_single_target_join_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 3 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 3, $statement_end ); + if ( null === $where_position || 3 >= $where_position || $where_position + 1 >= $statement_end ) { + return null; + } + + $delete_alias = $this->get_mysql_identifier_token_value( $tokens[1] ); + $target_ref = $this->parse_mysql_table_reference( $tokens, 3, $where_position ); + if ( null === $delete_alias || null === $target_ref ) { + return null; + } + + $target_alias = null === $target_ref['alias'] ? $target_ref['table'] : $target_ref['alias']; + if ( + strtolower( $delete_alias ) !== strtolower( $target_alias ) + && strtolower( $delete_alias ) !== strtolower( $target_ref['table'] ) + ) { + return null; + } + + if ( null === $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::JOIN_SYMBOL, $target_ref['position'], $where_position ) ) { + return null; + } + + $target_alias_sql = $this->connection->quote_identifier( $target_alias ); + + return sprintf( + 'DELETE FROM %s AS %s WHERE %s.ctid IN (SELECT %s.ctid FROM %s WHERE %s)', + $this->connection->quote_identifier( $target_ref['table'] ), + $target_alias_sql, + $target_alias_sql, + $target_alias_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 3, $where_position ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $statement_end ) + ); + } + + /** + * Check whether a WHERE clause is the null-rejected side of a LEFT JOIN. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @param string $alias Joined table alias. + * @return bool Whether the predicate matches ". IS NULL". + */ + private function is_mysql_null_rejected_join_alias_predicate( array $tokens, int $start, int $end, string $alias ): bool { + if ( + $start + 5 !== $end + || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + || WP_MySQL_Lexer::IS_SYMBOL !== ( $tokens[ $start + 3 ]->id ?? null ) + || WP_MySQL_Lexer::NULL_SYMBOL !== ( $tokens[ $start + 4 ]->id ?? null ) + ) { + return false; + } + + $predicate_alias = $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + return null !== $predicate_alias + && null !== $column + && strtolower( $predicate_alias ) === strtolower( $alias ); + } + /** * Translate simple single-table MySQL DELETE statements to PostgreSQL. * @@ -5153,6 +5462,14 @@ private function get_simple_replace_conflict_column( string $table_name, array $ return $column_lookup['option_name']; } + if ( $this->is_mysql_wordpress_table_name( $table_name, 'wc_customer_lookup' ) && isset( $column_lookup['customer_id'] ) ) { + return $column_lookup['customer_id']; + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'wc_product_meta_lookup' ) && isset( $column_lookup['product_id'] ) ) { + return $column_lookup['product_id']; + } + foreach ( array( 'id', @@ -5256,60 +5573,368 @@ private function translate_simple_mysql_insert_query( string $query ): ?array { } /** - * Store a MySQL-compatible insert ID after a successful insert-like query. + * Translate simple MySQL INSERT ... SELECT statements to PostgreSQL. * - * PostgreSQL PDO exposes the sequence value, which can be stale when the - * caller explicitly supplies an AUTO_INCREMENT value. MySQL reports that - * explicit value through mysqli_insert_id(), and WordPress relies on it. + * Action Scheduler uses INSERT ... SELECT FROM DUAL and then reads + * insert_id. The generic compatibility rewrite can produce executable SQL, + * but it does not mark the statement as insert-like. Keep this parser narrow: + * explicit table, explicit column list, then a SELECT body. * - * @param array $dml_query Translated DML query metadata. - * @param int $affected_rows Backend affected row count. + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when unsupported. */ - 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; + 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; } - $inserted_new_row = ! isset( $dml_query['inserted_new_row'] ) || $dml_query['inserted_new_row']; + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } - 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; + ++$position; + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; } - $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; + ++$position; + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; } - $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); - if ( null === $auto_increment_column ) { - $this->last_insert_id = 0; - return; + $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; } - $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( - $auto_increment_column, - $dml_query['columns'], - $this->get_dml_insert_id_value_rows( $dml_query ) - ); - if ( null !== $explicit_insert_id ) { - $this->last_insert_id = $explicit_insert_id; - return; + $select_start = $position; + $select_end = $statement_end; + $outer_replacements = array(); + $closing_replacement = array(); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( + null === $after_close + || $after_close !== $statement_end + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $statement_end - 1; + $outer_replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ); + $closing_replacement[] = array( + 'start' => $statement_end - 1, + 'end' => $statement_end, + 'sql' => '', + ); } - if ( ! $inserted_new_row ) { - $this->last_insert_id = 0; - return; + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; } - $this->last_insert_id = $this->get_connection_last_insert_id(); - } + $replacements = $this->get_mysql_insert_select_projection_replacements( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $replacements ) { + return null; + } + $replacements = array_merge( $outer_replacements, $replacements, $closing_replacement ); + + return array( + 'action' => 'insert', + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'inserted_new_row' => true, + ); + } + + /** + * Get projection replacements for INSERT ... SELECT target compatibility. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return array[]|null Replacement ranges, or null when unsupported. + */ + private function get_mysql_insert_select_projection_replacements( string $table_name, array $columns, array $tokens, int $select_start, int $select_end ): ?array { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + if ( null === $from_position || $select_start + 1 >= $from_position ) { + return array(); + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $from_position ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + if ( empty( $target_metadata ) ) { + return array(); + } + + $first_clause_position = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $select_end + ) ?? $select_end; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $first_clause_position ); + + $group_items = null; + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $from_position + 1, $select_end ); + if ( + null !== $group_position + && isset( $tokens[ $group_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $group_position + 1 ]->id + ) { + $group_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $group_position + 2, + $select_end + ) ?? $select_end; + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $group_end ); + if ( null === $group_items ) { + return null; + } + } + + $replacements = array(); + foreach ( $projection_ranges as $index => $range ) { + $column_key = strtolower( $columns[ $index ] ); + $column_metadata = $target_metadata[ $column_key ] ?? null; + if ( null === $column_metadata ) { + continue; + } + + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $expression_start = $expression_bounds['start']; + $expression_end = $expression_bounds['end']; + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ); + $changed = false; + if ( + null !== $group_items + && ! $this->is_mysql_insert_select_grouped_projection_expression( $tokens, $expression_start, $expression_end, $group_items ) + ) { + $projection_sql = sprintf( 'MIN(%s)', $projection_sql ); + $changed = true; + } + + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $column_metadata, + $tokens, + $expression_start, + $expression_end, + $projection_sql, + $scope + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + $changed = true; + } + + if ( ! $changed ) { + continue; + } + + $replacements[] = array( + 'start' => $range['start'], + 'end' => $range['end'], + 'sql' => $projection_sql, + ); + } + + return $replacements; + } + + /** + * Check whether an INSERT ... SELECT projection is already grouped or aggregate-safe. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the projection can be selected without an aggregate wrapper. + */ + private function is_mysql_insert_select_grouped_projection_expression( array $tokens, int $start, int $end, array $group_items ): bool { + if ( $this->is_mysql_constant_projection_expression( $tokens, $start, $end ) || $this->contains_mysql_aggregate_call( $tokens, $start, $end ) ) { + return true; + } + + foreach ( $group_items as $group_item ) { + if ( $this->are_mysql_token_ranges_equivalent( $tokens, $start, $end, $group_item['start'], $group_item['end'] ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a projection expression is a simple constant. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @return bool Whether the expression is constant. + */ + private function is_mysql_constant_projection_expression( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && ( + $this->is_mysql_string_literal_token( $tokens[ $start ] ) + || $this->is_mysql_numeric_literal_token( $tokens[ $start ] ) + || WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id + ); + } + + /** + * Coerce an INSERT ... SELECT projection to the target column type when needed. + * + * @param array $column_metadata Target column metadata. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $projection_sql Already translated projection SQL. + * @param array|null $scope Source SELECT table scope. + * @return string|null Coerced projection SQL, or null when generic SQL is sufficient. + */ + private function get_mysql_insert_select_projection_sql_for_target_column( array $column_metadata, array $tokens, int $start, int $end, string $projection_sql, ?array $scope ): ?string { + $target_type = (string) ( $column_metadata['column_type'] ?? '' ); + if ( $this->is_mysql_integer_family_column_type( $target_type ) ) { + if ( null !== $scope ) { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( null !== $reference && $reference['end'] === $end && $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + } + + if ( $this->is_mysql_integer_numeric_literal_range( $tokens, $start, $end ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( $projection_sql ); + } + + if ( ! $this->is_mysql_text_family_column_type( $target_type ) ) { + return null; + } + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return null; + } + + if ( null !== $scope ) { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( null !== $reference && $reference['end'] === $end && $this->is_mysql_text_family_column_reference( $reference, $scope ) ) { + return null; + } + } + + return sprintf( 'CAST(%s AS text)', $projection_sql ); + } + + /** + * Store a MySQL-compatible insert ID after a successful insert-like query. + * + * PostgreSQL PDO exposes the sequence value, which can be stale when the + * caller explicitly supplies an AUTO_INCREMENT value. MySQL reports that + * explicit value through mysqli_insert_id(), and WordPress relies on it. + * + * @param array $dml_query Translated DML query metadata. + * @param int $affected_rows Backend affected row count. + */ + private function set_last_insert_id_after_dml_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + $this->last_insert_id = 0; + return; + } + + $inserted_new_row = ! isset( $dml_query['inserted_new_row'] ) || $dml_query['inserted_new_row']; + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( (string) $dml_query['table_name'] ); + if ( empty( $metadata_lookup ) ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + $this->last_insert_id = 0; + return; + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( + $auto_increment_column, + $dml_query['columns'], + $this->get_dml_insert_id_value_rows( $dml_query ) + ); + if ( null !== $explicit_insert_id ) { + $this->last_insert_id = $explicit_insert_id; + return; + } + + if ( ! $inserted_new_row ) { + $this->last_insert_id = 0; + return; + } + + $this->last_insert_id = $this->get_connection_last_insert_id(); + } /** * Read the backend connection's last insert ID. @@ -5907,7 +6532,20 @@ private function translate_simple_mysql_update_set_clause( string $table_name, a $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); } if ( null === $value_sql ) { - $value_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment_end ); + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $this->get_mysql_single_table_scope( $table_name ) + ); + $value_sql = $expression_sql['sql']; + if ( + $expression_sql['changed'] + && null !== $target_metadata + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } } $quoted_target_column = $this->connection->quote_identifier( $target_column ); @@ -5986,7 +6624,49 @@ private function normalize_non_strict_mysql_dml_values_for_columns( array $colum * @return string|null PostgreSQL value SQL, or null when generic translation is sufficient. */ private function get_non_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { - return $this->get_non_strict_mysql_dml_date_time_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + $value_sql = $this->get_non_strict_mysql_dml_date_time_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $value_sql ) { + return $value_sql; + } + + return $this->get_non_strict_mysql_dml_integer_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + + /** + * Get a non-strict MySQL-compatible integer literal for a column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the literal does not need normalization. + */ + private function get_non_strict_mysql_dml_integer_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_integer_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + return null; + } + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + if ( 1 === preg_match( '/^[[:space:]]*[+-]?[0-9]+[[:space:]]*$/', $tokens[ $start ]->get_value() ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $start ] ) + ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null === $literal || $literal['start'] !== $start || $literal['end'] !== $end ) { + return null; + } + if ( $this->is_mysql_integer_numeric_literal_range( $tokens, $start, $end ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ) + ); } /** @@ -10720,6 +11400,9 @@ private function is_mysql_meta_value_cast_expression( array $tokens, int $start, if ( null === $cast_bounds ) { $cast_bounds = $this->get_mysql_decimal_cast_bounds( $tokens, $start, $end ); } + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_date_time_cast_bounds( $tokens, $start, $end ); + } return null !== $cast_bounds && $cast_bounds['close'] + 1 === $end @@ -11720,7 +12403,16 @@ private function get_mysql_select_statement_contextual_replacements( return null; } - $replacements = array(); + $replacements = $this->get_mysql_select_projection_contextual_replacements( + $tokens, + $projection_start, + $from_position, + $scope + ); + if ( null === $replacements ) { + return null; + } + if ( null !== $where_position ) { $where_end = $this->find_first_top_level_mysql_token( $tokens, @@ -11800,20 +12492,106 @@ private function get_mysql_select_statement_contextual_replacements( } /** - * Translate tokens while replacing known bounded token ranges. + * Get metadata-backed replacements for SELECT projection expressions. * - * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. - * @param int $start First token position. - * @param int $end Final token position, exclusive. - * @param array[] $replacements Replacement ranges with translated SQL. - * @return string PostgreSQL SQL fragment. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end FROM token position. + * @param array $scope Statement table scope. + * @return array[]|null Replacement ranges, or null when projection parsing fails. */ - private function translate_mysql_token_sequence_with_replacements_to_postgresql( - array $tokens, - int $start, - int $end, - array $replacements - ): string { + private function get_mysql_select_projection_contextual_replacements( array $tokens, int $start, int $end, array $scope ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges ) { + return null; + } + + $replacements = array(); + foreach ( $ranges as $range ) { + $expression_start = $range['start']; + $expression_end = $range['end']; + $projection_item = $this->parse_mysql_select_projection_item( $tokens, $range['start'], $range['end'] ); + if ( null !== $projection_item ) { + $expression_start = $projection_item['expression_start']; + $expression_end = $projection_item['expression_end']; + } + + $replacement_sql = $this->translate_mysql_sum_text_column_aggregate_to_postgresql( + $tokens, + $expression_start, + $expression_end, + $scope + ); + if ( null === $replacement_sql ) { + continue; + } + + $replacements[] = array( + 'start' => $expression_start, + 'end' => $expression_end, + 'sql' => $replacement_sql, + ); + } + + return $replacements; + } + + /** + * Translate SUM(text_column) with MySQL numeric text coercion. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection expression token. + * @param int $end Final projection expression token, exclusive. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL aggregate SQL, or null when unsupported. + */ + private function translate_mysql_sum_text_column_aggregate_to_postgresql( array $tokens, int $start, int $end, array $scope ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( + $start + 4 > $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::SUM_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $end - 1 ]->id + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start + 2, $end - 1 ); + if ( + null === $reference + || $reference['end'] !== $end - 1 + || ! $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + return null; + } + + return sprintf( + 'SUM(%s)', + $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ) + ); + } + + /** + * Translate tokens while replacing known bounded token ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_with_replacements_to_postgresql( + array $tokens, + int $start, + int $end, + array $replacements + ): string { $chunks = array(); $position = $start; @@ -12282,7 +13060,7 @@ private function translate_mysql_expression_token_sequence_to_postgresql( } } - $translated_expression = $this->translate_mysql_text_column_numeric_addition_to_postgresql( + $translated_expression = $this->translate_mysql_text_column_numeric_arithmetic_to_postgresql( $tokens, $position, $end, @@ -13863,7 +14641,7 @@ private function translate_mysql_text_column_numeric_comparison_to_postgresql( } /** - * Translate a text-column numeric addition expression used for sorting. + * Translate a text-column numeric arithmetic expression. * * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @param int $position Candidate expression position. @@ -13871,7 +14649,7 @@ private function translate_mysql_text_column_numeric_comparison_to_postgresql( * @param array $scope Statement table scope. * @return array{sql: string, position: int}|null Translation data, or null when unsupported. */ - private function translate_mysql_text_column_numeric_addition_to_postgresql( + private function translate_mysql_text_column_numeric_arithmetic_to_postgresql( array $tokens, int $position, int $end, @@ -13879,19 +14657,36 @@ private function translate_mysql_text_column_numeric_addition_to_postgresql( ): ?array { $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); if ( - null !== $reference - && isset( $tokens[ $reference['end'] ] ) - && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $reference['end'] ]->id - && $this->is_mysql_text_family_column_reference( $reference, $scope ) - ) { - $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); - if ( - null !== $literal - && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + 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' => $this->get_postgresql_mysql_numeric_cast_sql( - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + '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, ); @@ -13900,11 +14695,17 @@ private function translate_mysql_text_column_numeric_addition_to_postgresql( $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $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 - ) { + 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; } @@ -13913,12 +14714,28 @@ private function translate_mysql_text_column_numeric_addition_to_postgresql( return null; } - return array( - 'sql' => $this->get_postgresql_mysql_numeric_cast_sql( - $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) - ), - 'position' => $reference['end'] - 1, + $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, + ); } /** @@ -14349,6 +15166,41 @@ private function is_mysql_zero_numeric_literal_range( array $tokens, int $start, && 0.0 === (float) $tokens[ $start ]->get_value(); } + /** + * Check whether a numeric literal range is an integer token. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return bool Whether the literal is a signed or unsigned integer token. + */ + private function is_mysql_integer_numeric_literal_range( array $tokens, int $start, int $end ): bool { + if ( ! isset( $tokens[ $start ] ) ) { + return false; + } + + if ( + $start + 2 === $end + && ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + ) + ) { + ++$start; + } + + return $start + 1 === $end + && in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + /** * Check whether a token is a numeric literal. * @@ -14433,6 +15285,8 @@ private function is_supported_simple_mysql_expression_token( WP_MySQL_Token $tok $token->id, array( WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::DECIMAL_NUMBER, WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::FALSE_SYMBOL, @@ -14440,12 +15294,16 @@ private function is_supported_simple_mysql_expression_token( WP_MySQL_Token $tok WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, WP_MySQL_Lexer::GREATER_THAN_OPERATOR, WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::IN_SYMBOL, WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, WP_MySQL_Lexer::LESS_THAN_OPERATOR, WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::MINUS_OPERATOR, WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, WP_MySQL_Lexer::TRUE_SYMBOL, WP_MySQL_Lexer::ULONGLONG_NUMBER, @@ -14725,16 +15583,25 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in for ( $i = $start; $i < $end; $i++ ) { $token = $tokens[ $i ]; $fragment_token_id = $token->id; - $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); + $translated_fragment = $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $i, $end ); + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_field_function_to_postgresql( $tokens, $i, $end ); } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_integer_cast_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_integer_convert_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_character_cast_to_postgresql( $tokens, $i, $end ); } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_time_cast_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_binary_cast_to_postgresql( $tokens, $i, $end ); } @@ -14771,6 +15638,10 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in $fragment = $this->translate_mysql_token_to_postgresql( $token, $tokens[ $i + 1 ] ?? null ); } + if ( '' === $fragment ) { + continue; + } + if ( '' === $sql ) { $sql = $fragment; } elseif ( $this->should_join_mysql_tokens_without_space( $previous_token_id, $fragment_token_id ) ) { @@ -14785,6 +15656,66 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in return $sql; } + /** + * Translate MySQL's dummy DUAL table reference. + * + * MySQL accepts SELECT and INSERT ... SELECT statements with FROM DUAL as a + * one-row dummy table. PostgreSQL supports the same projections without a + * FROM clause, so erase only the exact unaliased FROM DUAL reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position FROM token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_dual_table_reference_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::DUAL_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + if ( + isset( $tokens[ $position + 2 ] ) + && $position + 2 < $end + && ! $this->is_mysql_dual_table_reference_boundary_token( $tokens[ $position + 2 ] ) + ) { + return null; + } + + return array( + 'sql' => '', + 'token_id' => WP_MySQL_Lexer::FROM_SYMBOL, + 'position' => $position + 1, + ); + } + + /** + * Check whether a token can follow an erased FROM DUAL reference. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token starts a clause or closes the SELECT. + */ + private function is_mysql_dual_table_reference_boundary_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + true + ); + } + /** * Translate MySQL LIMIT offset,count syntax to PostgreSQL LIMIT count OFFSET offset. * @@ -14920,6 +15851,33 @@ private function translate_mysql_integer_cast_to_postgresql( array $tokens, int ); } + /** + * Translate MySQL CONVERT(expr, SIGNED/UNSIGNED [INTEGER]) to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_integer_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_integer_cast_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + /** * Get PostgreSQL SQL for MySQL-compatible integer text coercion. * @@ -15021,6 +15979,50 @@ private function get_mysql_integer_cast_bounds( array $tokens, int $position, in ); } + /** + * Get token bounds for a supported MySQL integer CONVERT expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_integer_convert_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || null === $this->get_postgresql_integer_cast_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + /** * Get the PostgreSQL type for supported MySQL integer cast type tokens. * @@ -15159,6 +16161,103 @@ private function is_mysql_character_cast_type( array $tokens, int $start, int $e && WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $start ]->id; } + /** + * Translate MySQL CAST(expr AS DATETIME/TIMESTAMP) to PostgreSQL timestamp. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_time_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_time_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL date/time CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_time_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_date_time_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL DATETIME/TIMESTAMP. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_date_time_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::DATETIME_SYMBOL, + WP_MySQL_Lexer::TIMESTAMP_SYMBOL, + ), + true + ); + } + /** * Translate MySQL CAST(expr AS BINARY) to PostgreSQL text. * @@ -16439,6 +17538,10 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'field' ) ) { return true; } @@ -16447,6 +17550,14 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_integer_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_time_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->get_mysql_binary_cast_bounds( $tokens, $i, $end ) ) { return true; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 2bd7d1c79..665a71be4 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -607,6 +607,73 @@ public function test_simple_wordpress_replace_with_existing_id_is_translated_to_ $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); } + /** + * Tests WooCommerce customer lookup REPLACE statements use customer_id conflicts. + */ + public function test_simple_wordpress_replace_with_customer_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_wc_customer_lookup ( + customer_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + email TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_wc_customer_lookup (customer_id, user_id, email) VALUES (1, 1, 'old@example.com')" ); + + $replace = "REPLACE INTO `wptests_wc_customer_lookup` (`user_id`, `email`, `customer_id`) VALUES (2, 'new@example.com', '1')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_wc_customer_lookup" ("user_id", "email", "customer_id") VALUES (2, \'new@example.com\', \'1\') ON CONFLICT ("customer_id") DO UPDATE SET "user_id" = excluded."user_id", "email" = excluded."email", "customer_id" = excluded."customer_id"', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT user_id, email FROM wptests_wc_customer_lookup WHERE customer_id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->user_id ); + $this->assertSame( 'new@example.com', $rows[0]->email ); + } + + /** + * Tests WooCommerce product lookup REPLACE statements coerce integer values. + */ + public function test_woocommerce_product_lookup_replace_uses_product_id_conflict_and_integer_coercion(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_wc_product_meta_lookup ( + `product_id` bigint(20) unsigned NOT NULL, + `sku` varchar(100) NOT NULL DEFAULT "", + `total_sales` bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (`product_id`) + )' + ); + $driver->query( + "INSERT INTO wptests_wc_product_meta_lookup (`product_id`, `sku`, `total_sales`) VALUES (12, 'old-sku', 1)" + ); + + $replace = "REPLACE INTO `wptests_wc_product_meta_lookup` (`product_id`, `sku`, `total_sales`) VALUES ('12', 'DUMMY SKU100000', '4.000000')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'ON CONFLICT ("product_id") DO UPDATE SET', $queries[0]['sql'] ); + $this->assertStringContainsString( $this->get_expected_mysql_integer_cast_sql( "'4.000000'" ), $queries[0]['sql'] ); + + $rows = $driver->query( 'SELECT sku, total_sales FROM wptests_wc_product_meta_lookup WHERE product_id = 12' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'DUMMY SKU100000', $rows[0]->sku ); + $this->assertSame( '4', $rows[0]->total_sales ); + } + /** * Tests non-strict REPLACE applies omitted NOT NULL defaults on insert and conflict paths. */ @@ -1331,6 +1398,43 @@ public function test_get_insert_id_casts_numeric_strings(): void { $this->assertSame( 1, $driver->get_insert_id() ); } + /** + * Tests INSERT ... SELECT statements expose generated AUTO_INCREMENT insert IDs. + */ + public function test_insert_select_from_dual_sets_generated_insert_id(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_actionscheduler_actions ( + action_id INTEGER PRIMARY KEY AUTOINCREMENT, + hook TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_actionscheduler_actions ( + action_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + hook varchar(191) NOT NULL, + status varchar(20) NOT NULL, + PRIMARY KEY (action_id) + )' + ); + + $insert = "INSERT INTO wptests_actionscheduler_actions (`hook`, `status`) + SELECT 'action_scheduler/migration_hook', 'pending' FROM DUAL + WHERE ( SELECT NULL FROM DUAL ) IS NULL"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO wptests_actionscheduler_actions ("hook", "status") SELECT \'action_scheduler/migration_hook\', \'pending\' WHERE (SELECT NULL) IS NULL', + $queries[0]['sql'] + ); + } + /** * Tests explicit MySQL AUTO_INCREMENT values are exposed as the insert ID. */ @@ -1979,6 +2083,245 @@ function ( $row ) { ); } + /** + * Tests WooCommerce orphan cleanup DELETE statements are translated to anti-joins. + */ + public function test_mysql_left_join_orphan_delete_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY, + post_id INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_posts ("ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (1, 1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (2, 999)' ); + + $delete = 'DELETE meta FROM wptests_postmeta meta LEFT JOIN wptests_posts posts ON posts.ID = meta.post_id WHERE posts.ID IS NULL;'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS meta WHERE NOT EXISTS (SELECT 1 FROM "wptests_posts" AS posts WHERE posts."ID" = meta.post_id)', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT meta_id, post_id FROM wptests_postmeta ORDER BY meta_id' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->meta_id ); + $this->assertSame( '1', $rows[0]->post_id ); + } + + /** + * Tests MySQL joined DELETE statements with AS aliases are translated. + */ + public function test_mysql_join_delete_with_as_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE `postmeta` FROM `wptests_postmeta` AS `postmeta` + LEFT JOIN `wptests_posts` AS `posts` ON `posts`.`ID` = `postmeta`.`post_id` + WHERE `posts`.`post_type` = 'forum' + AND `postmeta`.`meta_key` = '_bbp_reply_count' + OR `postmeta`.`meta_key` = '_bbp_total_reply_count'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS "postmeta" WHERE "postmeta".ctid IN (SELECT "postmeta".ctid FROM "wptests_postmeta" AS "postmeta" LEFT JOIN "wptests_posts" AS "posts" ON "posts"."ID" = "postmeta"."post_id" WHERE "posts"."post_type" = \'forum\' AND "postmeta"."meta_key" = \'_bbp_reply_count\' OR "postmeta"."meta_key" = \'_bbp_total_reply_count\')', + $sql + ); + } + + /** + * Tests MySQL DUAL table references are erased in PostgreSQL-compatible SELECTs. + */ + public function test_mysql_dual_table_reference_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT 1 AS output FROM DUAL' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->output ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT 1 AS output', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL DUAL table references are erased in INSERT ... SELECT queries. + */ + public function test_insert_select_from_dual_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_actionscheduler_actions ( + hook TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_actionscheduler_actions (`hook`, `status`) + SELECT 'action_scheduler/migration_hook', 'pending' FROM DUAL + WHERE ( SELECT NULL FROM DUAL ) IS NULL"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO wptests_actionscheduler_actions ("hook", "status") SELECT \'action_scheduler/migration_hook\', \'pending\' WHERE (SELECT NULL) IS NULL', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT hook, status FROM wptests_actionscheduler_actions' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'action_scheduler/migration_hook', $rows[0]->hook ); + $this->assertSame( 'pending', $rows[0]->status ); + } + + /** + * Tests bbPress-style INSERT ... SELECT repair queries coerce target types. + */ + public function test_parenthesized_insert_select_coerces_target_columns_and_grouped_projections(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_parent INTEGER NOT NULL, + post_author INTEGER NOT NULL, + post_type TEXT NOT NULL, + post_status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_author` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_postmeta ( + `meta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL, + PRIMARY KEY (`meta_id`) + )' + ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (10, 0, 1, 'forum', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (100, 10, 3, 'topic', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (101, 100, 4, 'reply', 'publish')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (100, '_bbp_topic_id', '100')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (101, '_bbp_topic_id', '100')" ); + + $engagements_sql = "INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) ( + SELECT postmeta.meta_value, '_bbp_engagement', posts.post_author + FROM wptests_posts AS posts + LEFT JOIN wptests_postmeta AS postmeta + ON posts.ID = postmeta.post_id + AND postmeta.meta_key = '_bbp_topic_id' + WHERE posts.post_type IN ('topic', 'reply') + AND posts.post_status IN ('publish', 'closed') + GROUP BY postmeta.meta_value, posts.post_author)"; + + $this->assertSame( 2, $driver->query( $engagements_sql ) ); + + $engagement_queries = $driver->get_last_postgresql_queries(); + $engagement_sql = $engagement_queries[0]['sql']; + $this->assertStringContainsString( 'CASE WHEN CAST(postmeta.meta_value AS text) IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(CAST(postmeta.meta_value AS text)', $engagement_sql ); + $this->assertStringContainsString( 'CAST(posts.post_author AS text)', $engagement_sql ); + + $engagements = $driver->query( "SELECT post_id, meta_key, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_engagement' ORDER BY meta_value" ); + $this->assertCount( 2, $engagements ); + $this->assertSame( '100', $engagements[0]->post_id ); + $this->assertSame( '3', $engagements[0]->meta_value ); + $this->assertSame( '100', $engagements[1]->post_id ); + $this->assertSame( '4', $engagements[1]->meta_value ); + + $forum_meta_sql = "INSERT INTO `wptests_postmeta` (`post_id`, `meta_key`, `meta_value`) + ( SELECT `reply`.`ID`, '_bbp_forum_id', `topic`.`post_parent` + FROM `wptests_posts` + AS `reply` + INNER JOIN `wptests_posts` + AS `topic` + ON `reply`.`post_parent` = `topic`.`ID` + WHERE `topic`.`post_type` = 'topic' + AND `reply`.`post_type` = 'reply' + GROUP BY `reply`.`ID` )"; + + $this->assertSame( 1, $driver->query( $forum_meta_sql ) ); + + $forum_meta_queries = $driver->get_last_postgresql_queries(); + $this->assertStringContainsString( + 'CAST(MIN("topic"."post_parent") AS text)', + $forum_meta_queries[0]['sql'] + ); + + $forum_meta = $driver->query( "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_forum_id'" ); + $this->assertCount( 1, $forum_meta ); + $this->assertSame( '101', $forum_meta[0]->post_id ); + $this->assertSame( '10', $forum_meta[0]->meta_value ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (102, 100, 5, 'reply', 'spam')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (103, 100, 6, 'reply', 'pending')" ); + $hidden_reply_count_sql = "INSERT INTO `wptests_postmeta` (`post_id`, `meta_key`, `meta_value`) + (SELECT `post_parent`, '_bbp_reply_count_hidden', COUNT(`post_status`) as `meta_value` + FROM `wptests_posts` + WHERE `post_type` = 'reply' + AND `post_status` IN ('trash','spam','pending') + GROUP BY `post_parent`)"; + + $this->assertSame( 1, $driver->query( $hidden_reply_count_sql ) ); + + $hidden_reply_count_queries = $driver->get_last_postgresql_queries(); + $this->assertStringContainsString( + 'CAST(COUNT ("post_status") AS text)', + $hidden_reply_count_queries[0]['sql'] + ); + $this->assertStringNotContainsString( 'AS "meta_value" AS text', $hidden_reply_count_queries[0]['sql'] ); + + $hidden_reply_count = $driver->query( "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_reply_count_hidden'" ); + $this->assertCount( 1, $hidden_reply_count ); + $this->assertSame( '100', $hidden_reply_count[0]->post_id ); + $this->assertSame( '2', $hidden_reply_count[0]->meta_value ); + } + /** * Tests multi-assignment WordPress UPDATE statements are translated to PostgreSQL. */ @@ -2164,6 +2507,33 @@ public function test_convert_using_right_hand_compound_expression_preserves_grou ); } + /** + * Tests CONVERT(expr, SIGNED) expressions use MySQL integer coercion. + */ + public function test_convert_signed_expression_uses_mysql_integer_coercion(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( 'CREATE TABLE wptests_bp_groups_groupmeta (meta_value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_bp_groups_groupmeta (meta_value) VALUES ('10members')" ); + + $rows = $driver->query( + 'SELECT CONVERT(meta_value, SIGNED) AS member_count + FROM wptests_bp_groups_groupmeta + ORDER BY CONVERT(meta_value, SIGNED) DESC' + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $this->assertSame( '10', $rows[0]->member_count ); + $this->assertStringContainsString( + 'SELECT ' . $meta_value_cast_sql . ' AS member_count', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' DESC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + /** * Tests MySQL FIELD() expressions are translated for PostgreSQL ordering. */ @@ -2827,6 +3197,114 @@ static function ( $row ): string { ); } + /** + * Tests text metadata UPDATE additions use MySQL numeric coercion before text assignment. + */ + public function test_text_metadata_update_addition_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, '_order_total', '10')" ); + + $this->assertSame( + 1, + $driver->query( "UPDATE wptests_postmeta SET meta_value = meta_value + 4.000000 WHERE post_id = 1 AND meta_key = '_order_total'" ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + '"meta_value" = CAST(' . $meta_value_cast_sql . ' + 4.000000 AS text)', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_postmeta WHERE post_id = 1 AND meta_key = '_order_total'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '14.0', $rows[0]->meta_value ); + } + + /** + * Tests text metadata UPDATE subtractions use MySQL numeric coercion. + */ + public function test_text_metadata_update_subtraction_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_usermeta ( + `user_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_usermeta (`user_id`, `meta_key`, `meta_value`) VALUES (107, 'total_group_count', '7')" ); + + $this->assertSame( + 1, + $driver->query( "UPDATE wptests_usermeta SET meta_value = meta_value - 1 WHERE meta_key = 'total_group_count' AND user_id IN ( 107 )" ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + '"meta_value" = CAST(' . $meta_value_cast_sql . ' - 1 AS text)', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_usermeta WHERE user_id = 107 AND meta_key = 'total_group_count'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '6', $rows[0]->meta_value ); + } + + /** + * Tests text metadata SUM aggregates use MySQL numeric coercion from metadata. + */ + public function test_text_metadata_sum_aggregate_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_parent`) VALUES (2, 'shop_order_refund', 1)" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_parent`) VALUES (3, 'shop_order_refund', 1)" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, '_refund_amount', '2.25')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, '_refund_amount', '3')" ); + + $rows = $driver->query( + "SELECT SUM( postmeta.meta_value ) AS refunded + FROM wptests_postmeta AS postmeta + INNER JOIN wptests_posts AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent = 1 ) + WHERE postmeta.meta_key = '_refund_amount' + AND postmeta.post_id = posts.ID" + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'postmeta.meta_value' ); + $this->assertStringContainsString( + 'SUM(' . $meta_value_cast_sql . ') AS refunded', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '5.25', $rows[0]->refunded ); + } + /** * Tests DISTINCT ORDER BY rewrites keep numeric metadata ordering safe. */ @@ -3230,6 +3708,33 @@ public function test_sql_calc_found_rows_count_query_cache_reuses_exact_sql_unti ); } + /** + * Tests MySQL DATETIME casts in grouped postmeta ordering are translated. + */ + public function test_sql_calc_grouped_postmeta_order_by_datetime_cast_uses_postgresql_timestamp(): void { + $driver = $this->create_driver(); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_postmeta.meta_key = '_bbp_last_active_time' + AND wptests_posts.post_type = 'topic' + AND wptests_posts.post_status = 'publish' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS DATETIME) DESC + LIMIT 0, 15" + ); + + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['translated'] ); + $sql = $translation['sql']; + $this->assertStringContainsString( 'ORDER BY MAX(CAST(CASE WHEN', $sql ); + $this->assertStringContainsString( 'AS timestamp)) DESC', $sql ); + $this->assertStringNotContainsString( ' AS DATETIME', $sql ); + } + /** * Tests WordPress user text predicates and ordering preserve MySQL collation behavior. */ @@ -5916,6 +6421,68 @@ public function test_change_column_auto_increment_integer_family_preserves_ident $this->assertSame( 'auto_increment', $show[0]->Extra ); } + /** + * Tests MODIFY COLUMN clauses update backend and metadata definitions. + */ + public function test_modify_column_updates_backend_and_metadata_definitions(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_actionscheduler_actions ( + scheduled_date_gmt datetime NOT NULL default '2026-01-01 00:00:00', + last_attempt_gmt datetime NOT NULL default '2026-01-01 00:00:00' + )" + ); + + $driver->query( + "ALTER TABLE wptests_actionscheduler_actions + MODIFY COLUMN scheduled_date_gmt datetime NULL default '0000-00-00 00:00:00', + MODIFY COLUMN last_attempt_gmt datetime NULL default '0000-00-00 00:00:00'" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" SET DEFAULT \'0000-00-00 00:00:00\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" SET DEFAULT \'0000-00-00 00:00:00\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_actionscheduler_actions' ); + + $this->assertSame( 'scheduled_date_gmt', $describe[0]->Field ); + $this->assertSame( 'datetime', $describe[0]->Type ); + $this->assertSame( 'YES', $describe[0]->Null ); + $this->assertSame( '0000-00-00 00:00:00', $describe[0]->Default ); + $this->assertSame( 'last_attempt_gmt', $describe[1]->Field ); + $this->assertSame( 'datetime', $describe[1]->Type ); + $this->assertSame( 'YES', $describe[1]->Null ); + $this->assertSame( '0000-00-00 00:00:00', $describe[1]->Default ); + } + /** * Tests unsupported SHOW COLUMNS clauses do not fall through to the backend. */ From 10df813f7dfa9fdb087f6fd13ed793acf8bc6a66 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 13:36:51 +0000 Subject: [PATCH 110/142] Add PostgreSQL SHOW metadata support Support MySQL SHOW COLLATION and SHOW DATABASES/SCHEMAS in the PostgreSQL driver using tokenized statement parsing and static MySQL-shaped result rows. --- .../postgresql/class-wp-postgresql-driver.php | 352 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 68 ++++ 2 files changed, 420 insertions(+) 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 index bd5b51d8a..f3d2a316e 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -439,6 +439,16 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_show_variables_query( $show_variables_query, $fetch_mode, ...$fetch_mode_args ); } + $show_collation_query = $this->get_show_collation_query( $query ); + if ( null !== $show_collation_query ) { + return $this->execute_show_collation_query( $show_collation_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_databases_query = $this->get_show_databases_query( $query ); + if ( null !== $show_databases_query ) { + return $this->execute_show_databases_query( $show_databases_query, $fetch_mode, ...$fetch_mode_args ); + } + if ( $this->is_found_rows_query( $query ) ) { $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); $this->last_column_meta = array( @@ -3145,6 +3155,157 @@ private function get_show_variables_query( string $query ): ?array { return null; } + /** + * Parse a supported MySQL SHOW COLLATION statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW COLLATION options, or null when this is not SHOW COLLATION. + */ + private function get_show_collation_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::COLLATION_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $allowed_columns = array( + 'collation' => 'Collation', + 'charset' => 'Charset', + 'id' => 'Id', + 'default' => 'Default', + 'compiled' => 'Compiled', + 'sortlen' => 'Sortlen', + 'pad_attribute' => 'Pad_attribute', + ); + $filter = $this->get_show_static_result_filter( $tokens, 2, 'Collation', $allowed_columns ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLLATION statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW DATABASES/SHOW SCHEMAS statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW DATABASES options, or null when this is not SHOW DATABASES. + */ + private function get_show_databases_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || ( + WP_MySQL_Lexer::DATABASES_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::SCHEMAS_SYMBOL !== $tokens[1]->id + ) + ) { + return null; + } + + $filter = $this->get_show_static_result_filter( + $tokens, + 2, + 'Database', + array( 'database' => 'Database' ) + ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW DATABASES statement.' ); + } + + return $filter; + } + + /** + * Parse optional LIKE or simple WHERE filters for static SHOW result sets. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param string $like_column Output column filtered by LIKE. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @return array{type: string, column: string|null, pattern: string|null}|null Parsed filter, or null when unsupported. + */ + private function get_show_static_result_filter( + array $tokens, + int $position, + string $like_column, + array $allowed_columns + ): ?array { + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + 'column' => null, + 'pattern' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'type' => 'like', + 'column' => $like_column, + 'pattern' => $tokens[ $position + 1 ]->get_value(), + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 2 ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 4 ) + ) { + $column = $this->get_mysql_show_output_column_name( $tokens[ $position + 1 ], $allowed_columns ); + if ( null === $column ) { + return null; + } + + return array( + 'type' => 'exact', + 'column' => $column, + 'pattern' => $tokens[ $position + 3 ]->get_value(), + ); + } + + return null; + } + + /** + * Get the MySQL SHOW output column name represented by a token. + * + * @param WP_MySQL_Token $token MySQL token. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @return string|null Output column name, or null when unsupported. + */ + private function get_mysql_show_output_column_name( WP_MySQL_Token $token, array $allowed_columns ): ?string { + $column = $this->get_mysql_identifier_token_value( $token ); + if ( null === $column ) { + $column = $token->get_value(); + } + + $column_key = strtolower( $column ); + return $allowed_columns[ $column_key ] ?? null; + } + + /** + * Check whether a MySQL token is a quoted text literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is quoted text. + */ + private function is_mysql_quoted_text_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id; + } + /** * Parse a supported MySQL SHOW COLUMNS/SHOW FULL COLUMNS statement. * @@ -3552,6 +3713,197 @@ static function ( array $row ) { return $this->last_result; } + /** + * Execute a MySQL SHOW COLLATION statement from static MySQL-compatible metadata. + * + * @param array $show_collation_query SHOW COLLATION options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW COLLATION result rows. + */ + private function execute_show_collation_query( array $show_collation_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + array( + array( + 'Collation' => 'binary', + 'Charset' => 'binary', + 'Id' => '63', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'NO PAD', + ), + array( + 'Collation' => 'utf8_bin', + 'Charset' => 'utf8', + 'Id' => '83', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_general_ci', + 'Charset' => 'utf8', + 'Id' => '33', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_unicode_ci', + 'Charset' => 'utf8', + 'Id' => '192', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '8', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_bin', + 'Charset' => 'utf8mb4', + 'Id' => '46', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_unicode_ci', + 'Charset' => 'utf8mb4', + 'Id' => '224', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '8', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Charset' => 'utf8mb4', + 'Id' => '255', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '0', + 'Pad_attribute' => 'NO PAD', + ), + ), + $show_collation_query + ); + + return $this->set_mysql_static_show_result( + array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW DATABASES/SHOW SCHEMAS statement from emulated database metadata. + * + * @param array $show_databases_query SHOW DATABASES options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW DATABASES result rows. + */ + private function execute_show_databases_query( array $show_databases_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + array( + array( 'Database' => 'information_schema' ), + array( 'Database' => $this->db_name ), + ), + $show_databases_query + ); + + return $this->set_mysql_static_show_result( + array( 'Database' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Filter static SHOW rows with a parsed MySQL LIKE or WHERE filter. + * + * @param array[] $rows Rows keyed by output column names. + * @param array $show_filter Parsed SHOW filter. + * @return array[] Filtered rows. + */ + private function filter_mysql_static_show_rows( array $rows, array $show_filter ): array { + if ( 'all' === $show_filter['type'] ) { + return $rows; + } + + $column = $show_filter['column']; + $pattern = $show_filter['pattern']; + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_filter, $column, $pattern ): bool { + if ( null === $column || null === $pattern || ! array_key_exists( $column, $row ) ) { + return false; + } + + if ( 'like' === $show_filter['type'] ) { + return $this->matches_mysql_like_pattern( (string) $row[ $column ], $pattern ); + } + + return 0 === strcasecmp( (string) $row[ $column ], $pattern ); + } + ) + ); + } + + /** + * Store static SHOW result rows using common MySQL-shaped metadata. + * + * @param string[] $columns Result column names. + * @param array[] $rows Rows keyed by column names. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Result rows formatted for the requested fetch mode. + */ + private function set_mysql_static_show_result( array $columns, array $rows, $fetch_mode, ...$fetch_mode_args ) { + $this->last_column_meta = array(); + foreach ( $columns as $column ) { + $this->last_column_meta[] = array( + 'name' => $column, + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => $column, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ); + } + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + /** * Match a string against a MySQL LIKE pattern. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 665a71be4..3653589a7 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6583,6 +6583,74 @@ function ( $row ) { ); } + /** + * 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 ) ); + } + + /** + * Tests SHOW DATABASES and SHOW SCHEMAS return MySQL-shaped database rows. + */ + public function test_show_databases_and_schemas_return_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $databases = $driver->query( 'SHOW DATABASES' ); + + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => 'wptests' ), + ), + $databases + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'Database' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $like_rows = $driver->query( 'SHOW DATABASES LIKE "w%"' ); + $this->assertEquals( array( (object) array( 'Database' => 'wptests' ) ), $like_rows ); + + $where_rows = $driver->query( 'SHOW DATABASES WHERE `Database` = "information_schema"' ); + $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_rows ); + + $schemas = $driver->query( 'SHOW SCHEMAS' ); + $this->assertEquals( $databases, $schemas ); + } + /** * Tests Site Health's information_schema.TABLES query returns rows for existing catalog tables only. */ From ef7f09e314942791bd8e9c23a3d31136bec9539b Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 13:43:47 +0000 Subject: [PATCH 111/142] Reject quoted SHOW metadata WHERE columns --- .../postgresql/class-wp-postgresql-driver.php | 24 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 25 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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 index f3d2a316e..5fa26ea1a 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3287,14 +3287,36 @@ private function get_show_static_result_filter( */ 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 ) { + if ( null === $column && $this->is_mysql_show_output_column_keyword_token( $token ) ) { $column = $token->get_value(); } + if ( null === $column ) { + return null; + } $column_key = strtolower( $column ); return $allowed_columns[ $column_key ] ?? null; } + /** + * Check whether a MySQL keyword token can represent a SHOW output column. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a supported SHOW output column keyword. + */ + private function is_mysql_show_output_column_keyword_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::COLLATION_SYMBOL, + WP_MySQL_Lexer::DATABASE_SYMBOL, + WP_MySQL_Lexer::DEFAULT_SYMBOL, + ), + true + ); + } + /** * Check whether a MySQL token is a quoted text literal. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 3653589a7..1b85b4cc3 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6621,6 +6621,14 @@ static function ( $row ) { $where_rows = $driver->query( "SHOW COLLATION WHERE Collation = 'utf8_bin'" ); $this->assertSame( array( 'utf8_bin' ), array( $where_rows[0]->Collation ) ); + + try { + $driver->query( "SHOW COLLATION WHERE 'Collation' = 'utf8_bin'" ); + $this->fail( 'Expected quoted SHOW COLLATION WHERE left operand to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLLATION statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } } /** @@ -6647,6 +6655,23 @@ public function test_show_databases_and_schemas_return_mysql_shaped_rows(): void $where_rows = $driver->query( 'SHOW DATABASES WHERE `Database` = "information_schema"' ); $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_rows ); + $where_rows = $driver->query( "SHOW DATABASES WHERE Database = 'information_schema'" ); + $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_rows ); + + $unsupported_where_queries = array( + "SHOW DATABASES WHERE 'Database' = 'information_schema'", + 'SHOW DATABASES WHERE "Database" = \'information_schema\'', + ); + foreach ( $unsupported_where_queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected quoted SHOW DATABASES WHERE left operand to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW DATABASES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + $schemas = $driver->query( 'SHOW SCHEMAS' ); $this->assertEquals( $databases, $schemas ); } From 5999f0d076b1e0e3fa406fe3f8e6e2560bb64d2b Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 13:55:20 +0000 Subject: [PATCH 112/142] Add PostgreSQL table administration support --- .../postgresql/class-wp-postgresql-driver.php | 285 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 173 +++++++++++ 2 files changed, 458 insertions(+) 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 index 5fa26ea1a..d15dec687 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -536,6 +536,15 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo ); } + $table_administration_query = $this->get_mysql_table_administration_query( $query ); + if ( null !== $table_administration_query ) { + return $this->execute_mysql_table_administration_query( + $table_administration_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + $translated_for_postgresql = false; $dml_identity_repair_query = null; @@ -3516,6 +3525,282 @@ private function get_show_index_query( string $query ): ?array { ); } + /** + * Parse a supported MySQL table administration statement. + * + * @param string $query MySQL query. + * @return array{operation: string, tables: array}|null Administration query, or null when this is not a table administration statement. + */ + private function get_mysql_table_administration_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::ANALYZE_SYMBOL: + $operation = 'analyze'; + break; + case WP_MySQL_Lexer::CHECK_SYMBOL: + $operation = 'check'; + break; + case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: + $operation = 'optimize'; + break; + case WP_MySQL_Lexer::REPAIR_SYMBOL: + $operation = 'repair'; + break; + default: + return null; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables = array(); + $position = 2; + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables[] = $table_reference; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + return array( + 'operation' => $operation, + 'tables' => $tables, + ); + } + + /** + * Parse one table reference from a MySQL table administration statement. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{schema: string|null, table: string}|null Parsed table reference, or null when unsupported. + */ + private function get_mysql_table_administration_table_reference( array $tokens, int &$position ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + ); + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + $position += 2; + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + ); + } + + /** + * Execute a MySQL table administration statement. + * + * @param array $administration_query Parsed administration query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Administration result rows. + */ + private function execute_mysql_table_administration_query( array $administration_query, $fetch_mode, ...$fetch_mode_args ) { + $operation = $administration_query['operation']; + $rows = array(); + + foreach ( $administration_query['tables'] as $table_reference ) { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( null !== $requested_schema && 'information_schema' === strtolower( $requested_schema ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $table_label = $this->get_mysql_table_administration_result_table_name( $requested_schema, $table_name ); + if ( $this->mysql_table_administration_table_exists( $requested_schema, $table_name ) ) { + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ); + continue; + } + + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'Error', + 'Msg_text' => sprintf( "Table '%s' doesn't exist", $table_name ), + ); + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ); + } + + return $this->set_mysql_static_show_result( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Get the MySQL-facing Table column value for an administration result row. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return string MySQL-facing qualified table name. + */ + private function get_mysql_table_administration_result_table_name( ?string $requested_schema, string $table_name ): string { + $display_schema = null === $requested_schema ? $this->db_name : $requested_schema; + return $display_schema . '.' . $table_name; + } + + /** + * Check whether a table administration target exists. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return bool Whether the backend table exists. + */ + private function mysql_table_administration_table_exists( ?string $requested_schema, string $table_name ): bool { + $schema_name = $this->get_mysql_table_administration_backend_schema( $requested_schema, $table_name ); + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if ( 'sqlite' === $driver_name ) { + return $this->sqlite_table_administration_table_exists( $schema_name, $table_name ); + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM information_schema.tables + WHERE table_schema = ? + AND table_name = ? + AND table_type = \'BASE TABLE\' + LIMIT 1', + array( $schema_name, $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Resolve the backend schema for a MySQL table administration target. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return string Backend schema name. + */ + private function get_mysql_table_administration_backend_schema( ?string $requested_schema, string $table_name ): string { + if ( null === $requested_schema || 0 === strcasecmp( $requested_schema, $this->db_name ) ) { + return $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + } + + return $this->resolve_mysql_table_schema_for_introspection( $requested_schema, $table_name ); + } + + /** + * Check whether a SQLite-backed test table administration target exists. + * + * @param string $schema_name Backend schema name. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_administration_table_exists( string $schema_name, string $table_name ): bool { + if ( 'temp' === $schema_name ) { + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_temp_master', $table_name ); + } + + if ( 'public' === $schema_name ) { + if ( + $this->sqlite_database_schema_exists( 'public' ) + && $this->sqlite_table_administration_table_exists_in_catalog( + $this->connection->quote_identifier( 'public' ) . '.sqlite_master', + $table_name + ) + ) { + return true; + } + + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_master', $table_name ); + } + + if ( 'main' === $schema_name ) { + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_master', $table_name ); + } + + if ( ! $this->sqlite_database_schema_exists( $schema_name ) ) { + return false; + } + + return $this->sqlite_table_administration_table_exists_in_catalog( + $this->connection->quote_identifier( $schema_name ) . '.sqlite_master', + $table_name + ); + } + + /** + * Check whether a table exists in one SQLite catalog table. + * + * @param string $catalog_sql SQLite catalog table SQL. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_administration_table_exists_in_catalog( string $catalog_sql, string $table_name ): bool { + $stmt = $this->connection->query( + sprintf( + 'SELECT name FROM %s WHERE type = \'table\' AND name = ? LIMIT 1', + $catalog_sql + ), + array( $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether a SQLite attached database schema exists. + * + * @param string $schema_name Schema name. + * @return bool Whether the schema exists. + */ + private function sqlite_database_schema_exists( string $schema_name ): bool { + $stmt = $this->connection->query( 'PRAGMA database_list' ); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $database ) { + if ( isset( $database['name'] ) && $schema_name === (string) $database['name'] ) { + return true; + } + } + + return false; + } + /** * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 1b85b4cc3..d329fe929 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6676,6 +6676,179 @@ public function test_show_databases_and_schemas_return_mysql_shaped_rows(): void $this->assertEquals( $databases, $schemas ); } + /** + * Tests table administration statements return MySQL-shaped success rows. + */ + public function test_table_administration_statements_return_mysql_shaped_success_rows(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_two (id INTEGER)' ); + + $cases = array( + 'ANALYZE TABLE administration_one' => 'analyze', + 'CHECK TABLE `administration_one`' => 'check', + 'OPTIMIZE TABLE administration_one' => 'optimize', + 'REPAIR TABLE administration_one' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_one', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + array_column( $driver->get_last_column_meta(), 'name' ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $qualified_rows = $driver->query( 'CHECK TABLE `wptests`.`administration_two`' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_two', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $qualified_rows + ); + } + + /** + * Tests table administration statements preserve multiple-table order. + */ + public function test_table_administration_multiple_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_first (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_second (id INTEGER)' ); + + $rows = $driver->query( 'CHECK TABLE administration_second, administration_first' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_second', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_first', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements return MySQL-shaped missing-table errors. + */ + public function test_table_administration_missing_table_returns_error_and_failed_status(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'OPTIMIZE TABLE administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements preserve mixed existing and missing table order. + */ + public function test_table_administration_mixed_existing_and_missing_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $rows = $driver->query( 'REPAIR TABLE administration_existing, administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_existing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests unsupported table administration clauses fail before reaching the backend. + */ + public function test_table_administration_unsupported_clauses_fail_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + try { + $driver->query( 'CHECK TABLE administration_existing FOR UPGRADE' ); + $this->fail( 'Expected unsupported table administration clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests information_schema table administration targets fail closed. + */ + public function test_table_administration_information_schema_target_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CHECK TABLE `information_schema`.`tables`' ); + $this->fail( 'Expected information_schema table administration target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests Site Health's information_schema.TABLES query returns rows for existing catalog tables only. */ From edd67f983d7f900a0c5facc3a8c80561c5cf0236 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 14:06:19 +0000 Subject: [PATCH 113/142] Fix PostgreSQL admin checks for temp tables --- .../postgresql/class-wp-postgresql-driver.php | 10 +- .../tests/WP_PostgreSQL_Driver_Tests.php | 162 ++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) 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 index d15dec687..5c0cf6b06 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3699,10 +3699,12 @@ private function mysql_table_administration_table_exists( ?string $requested_sch $stmt = $this->connection->query( 'SELECT 1 - FROM information_schema.tables - WHERE table_schema = ? - AND table_name = ? - AND table_type = \'BASE TABLE\' + 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 ) ); diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index d329fe929..eab529cf4 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6728,6 +6728,47 @@ public function test_table_administration_statements_return_mysql_shaped_success ); } + /** + * Tests table administration statements treat PostgreSQL temporary tables as existing. + */ + public function test_table_administration_statements_treat_postgresql_temporary_table_as_existing(): void { + $connection = $this->create_table_administration_catalog_fixture_connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $cases = array( + 'ANALYZE TABLE administration_temp' => 'analyze', + 'CHECK TABLE administration_temp' => 'check', + 'OPTIMIZE TABLE administration_temp' => 'optimize', + 'REPAIR TABLE administration_temp' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_temp', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $catalog_queries = $connection->get_table_administration_catalog_queries(); + $this->assertCount( count( $cases ), $catalog_queries ); + + foreach ( $catalog_queries as $catalog_query ) { + $this->assertStringContainsString( 'FROM pg_catalog.pg_class c', $catalog_query['sql'] ); + $this->assertSame( array( 'pg_temp_7', 'administration_temp' ), $catalog_query['params'] ); + } + } + /** * Tests table administration statements preserve multiple-table order. */ @@ -7595,6 +7636,127 @@ private function create_driver_with_postgresql_quote_translation(): WP_PostgreSQ return new WP_PostgreSQL_Driver( $connection, 'wptests' ); } + /** + * Creates a connection fixture that exercises production PostgreSQL table administration catalogs. + * + * @return WP_PostgreSQL_Connection Connection fixture. + */ + private function create_table_administration_catalog_fixture_connection(): WP_PostgreSQL_Connection { + $pdo = new class( 'sqlite::memory:' ) extends PDO { + /** + * Report PostgreSQL for branch selection while keeping SQLite execution available. + * + * @param int $attribute PDO attribute. + * @return mixed Attribute value. + */ + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + return 'pgsql'; + } + + return parent::getAttribute( $attribute ); + } + }; + + return new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { + /** + * PostgreSQL catalog existence queries issued by the driver. + * + * @var array[] + */ + private $table_administration_catalog_queries = array(); + + /** + * Constructor. + * + * @param array $options Connection options. + */ + public function __construct( array $options ) { + parent::__construct( $options ); + + $pdo = $this->get_pdo(); + $pdo->exec( + 'CREATE TABLE table_administration_catalog_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + relkind TEXT NOT NULL + )' + ); + $pdo->exec( + "INSERT INTO table_administration_catalog_fixture + (table_schema, table_name, relkind) + VALUES ('pg_temp_7', 'administration_temp', 'r')" + ); + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( + 'CREATE TABLE information_schema.tables ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + table_type TEXT NOT NULL + )' + ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES ('pg_temp_7', 'administration_temp', 'LOCAL TEMPORARY')" + ); + } + + /** + * Execute fixture-backed PostgreSQL catalog queries. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( + false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) + && false !== strpos( $sql, 'pg_my_temp_schema()' ) + ) { + return parent::query( + 'SELECT table_schema AS nspname + FROM table_administration_catalog_fixture + WHERE table_schema = \'pg_temp_7\' + AND lower(table_name) = lower(?) + AND relkind IN (\'r\', \'p\') + LIMIT 1', + array( $params[0] ?? '' ) + ); + } + + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) ) { + $this->table_administration_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return parent::query( + 'SELECT 1 + FROM table_administration_catalog_fixture + WHERE table_schema = ? + AND table_name = ? + AND relkind IN (\'r\', \'p\') + LIMIT 1', + $params + ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get captured table administration catalog queries. + * + * @return array[] Catalog queries. + */ + public function get_table_administration_catalog_queries(): array { + return $this->table_administration_catalog_queries; + } + }; + } + /** * Quote a MySQL string literal for parser-facing tests. * From 09496b6ab01eb5d2c70f0edaa600738fc6938868 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 14:20:29 +0000 Subject: [PATCH 114/142] Add PostgreSQL SHOW TABLE STATUS support --- .../postgresql/class-wp-postgresql-driver.php | 444 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 223 +++++++++ 2 files changed, 667 insertions(+) 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 index 5c0cf6b06..d0a0997e7 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -514,6 +514,15 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo ); } + $show_table_status_query = $this->get_show_table_status_query( $query ); + if ( null !== $show_table_status_query ) { + return $this->execute_show_table_status_query( + $show_table_status_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + $show_columns_query = $this->get_show_columns_query( $query ); if ( null !== $show_columns_query ) { return $this->execute_show_columns_query( @@ -3118,6 +3127,140 @@ private function get_show_tables_query( string $query ): ?array { ); } + /** + * Parse a supported MySQL SHOW TABLE STATUS statement. + * + * @param string $query MySQL query. + * @return array{filter_type: string, filter_pattern: string|null, filter_threshold: string|null}|null SHOW TABLE STATUS options, or null when this is not SHOW TABLE STATUS. + */ + private function get_show_table_status_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::STATUS_SYMBOL !== $tokens[2]->id + ) { + return null; + } + + $position = 3; + 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 || 0 !== strcasecmp( $database_name, $this->db_name ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + $position += 2; + } + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'filter_type' => 'all', + 'filter_pattern' => null, + 'filter_threshold' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'filter_type' => 'like', + 'filter_pattern' => $tokens[ $position + 1 ]->get_value(), + 'filter_threshold' => null, + ); + } + + if ( + isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + ) { + $filter = $this->get_show_table_status_where_filter( $tokens, $position + 1 ); + if ( null !== $filter ) { + return $filter; + } + } + + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + /** + * Parse a supported SHOW TABLE STATUS WHERE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First WHERE predicate token position. + * @return array{filter_type: string, filter_pattern: string|null, filter_threshold: string|null}|null Parsed filter, or null when unsupported. + */ + private function get_show_table_status_where_filter( array $tokens, int $position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( + $tokens[ $position ], + array( 'auto_increment' => 'Auto_increment' ) + ); + if ( 'Auto_increment' !== $column || ! isset( $tokens[ $position + 1 ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::GREATER_THAN_OPERATOR === $tokens[ $position + 1 ]->id + && isset( $tokens[ $position + 2 ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $position + 2 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_gt', + 'filter_pattern' => null, + 'filter_threshold' => $tokens[ $position + 2 ]->get_value(), + ); + } + + if ( + WP_MySQL_Lexer::IS_SYMBOL === $tokens[ $position + 1 ]->id + && isset( $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 2 ]->id + && $this->is_at_mysql_query_end( $tokens, $position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_is_null', + 'filter_pattern' => null, + 'filter_threshold' => null, + ); + } + + return null; + } + + /** + * Check whether a token is an unsigned integer literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an unsigned integer literal. + */ + private function is_mysql_unsigned_integer_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + /** * Parse a supported MySQL SHOW VARIABLES statement. * @@ -3317,6 +3460,7 @@ private function is_mysql_show_output_column_keyword_token( WP_MySQL_Token $toke return in_array( $token->id, array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, WP_MySQL_Lexer::CHARSET_SYMBOL, WP_MySQL_Lexer::COLLATION_SYMBOL, WP_MySQL_Lexer::DATABASE_SYMBOL, @@ -3940,6 +4084,306 @@ private function execute_show_tables_query( bool $is_full, ?string $like, $fetch return $this->last_result; } + /** + * Execute a MySQL SHOW TABLE STATUS statement through PostgreSQL catalogs. + * + * @param array $show_table_status_query SHOW TABLE STATUS options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW TABLE STATUS result rows. + */ + private function execute_show_table_status_query( array $show_table_status_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = array(); + foreach ( $this->get_show_table_status_catalog_rows() as $catalog_row ) { + $table_name = (string) $catalog_row['table_name']; + $identity_column = isset( $catalog_row['identity_column'] ) && null !== $catalog_row['identity_column'] + ? (string) $catalog_row['identity_column'] + : null; + + $rows[] = $this->get_show_table_status_result_row( + $table_name, + null === $identity_column + ? null + : $this->get_show_table_status_auto_increment_value( $table_name, $identity_column ) + ); + } + + $rows = $this->filter_show_table_status_rows( $rows, $show_table_status_query ); + + return $this->set_mysql_static_show_result( + array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Get base table rows used by SHOW TABLE STATUS. + * + * @return array[] Catalog rows. + */ + private function get_show_table_status_catalog_rows(): array { + $sql = 'SELECT + t.table_name, + ( + SELECT c.column_name + FROM information_schema.columns c + WHERE c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND ( + c.is_identity = \'YES\' + OR LOWER(COALESCE(c.column_default, \'\')) LIKE \'nextval(%\' + ) + ORDER BY c.ordinal_position + LIMIT 1 + ) AS identity_column + FROM information_schema.tables t + WHERE t.table_schema = ? + AND t.table_type = ? + AND t.table_name NOT IN (?, ?, ?) + ORDER BY t.table_name'; + $params = array( + 'public', + 'BASE TABLE', + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_CHARSET_METADATA_TABLE, + ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Build a MySQL-shaped SHOW TABLE STATUS row. + * + * @param string $table_name Table name. + * @param string|null $auto_increment Next auto-increment value, or null. + * @return array MySQL-shaped row. + */ + private function get_show_table_status_result_row( string $table_name, ?string $auto_increment ): array { + return array( + 'Name' => $table_name, + 'Engine' => 'InnoDB', + 'Version' => '10', + 'Row_format' => 'Dynamic', + 'Rows' => '0', + 'Avg_row_length' => '0', + 'Data_length' => '0', + 'Max_data_length' => '0', + 'Index_length' => '0', + 'Data_free' => '0', + 'Auto_increment' => $auto_increment, + 'Create_time' => null, + 'Update_time' => null, + 'Check_time' => null, + 'Collation' => $this->collation, + 'Checksum' => null, + 'Create_options' => '', + 'Comment' => '', + ); + } + + /** + * Get the next MySQL-compatible AUTO_INCREMENT value for a table. + * + * @param string $table_name Table name. + * @param string $identity_column Identity column name. + * @return string|null Next AUTO_INCREMENT value, or null when unavailable. + */ + private function get_show_table_status_auto_increment_value( string $table_name, string $identity_column ): ?string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'pgsql' === $driver_name ) { + return $this->get_postgresql_show_table_status_auto_increment_value( $table_name, $identity_column ); + } + + if ( 'sqlite' === $driver_name ) { + return $this->get_sqlite_show_table_status_auto_increment_value( $table_name ); + } + + return null; + } + + /** + * Get the next AUTO_INCREMENT value from PostgreSQL identity sequence state. + * + * @param string $table_name Table name. + * @param string $identity_column Identity column name. + * @return string|null Next AUTO_INCREMENT value, or null when unavailable. + */ + private function get_postgresql_show_table_status_auto_increment_value( string $table_name, string $identity_column ): ?string { + $sequence_sql = 'SELECT + seq_ns.nspname AS sequence_schema, + seq.relname AS sequence_name + FROM ( + SELECT pg_catalog.pg_get_serial_sequence(format(\'%I.%I\', ?, ?), ?)::regclass AS sequence_oid + ) identity_sequence + LEFT JOIN pg_catalog.pg_class seq + ON seq.oid = identity_sequence.sequence_oid + LEFT JOIN pg_catalog.pg_namespace seq_ns + ON seq_ns.oid = seq.relnamespace'; + $stmt = $this->connection->query( + $sequence_sql, + array( 'public', $table_name, $identity_column ) + ); + $this->last_postgresql_queries[] = array( + 'sql' => $sequence_sql, + 'params' => array( 'public', $table_name, $identity_column ), + ); + + $sequence = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( + false === $sequence + || empty( $sequence['sequence_schema'] ) + || empty( $sequence['sequence_name'] ) + ) { + return '1'; + } + + $sequence_identifier = $this->get_postgresql_qualified_identifier( + (string) $sequence['sequence_schema'], + (string) $sequence['sequence_name'] + ); + $table_identifier = $this->get_postgresql_qualified_identifier( 'public', $table_name ); + $sql = sprintf( + 'WITH sequence_state AS ( + SELECT last_value, is_called FROM %1$s + ), + table_state AS ( + SELECT MAX(%2$s) AS max_identity_value FROM %3$s + ) + SELECT GREATEST( + CASE WHEN sequence_state.is_called THEN sequence_state.last_value + 1 ELSE sequence_state.last_value END, + COALESCE(table_state.max_identity_value + 1, 1) + ) AS auto_increment + FROM sequence_state, table_state', + $sequence_identifier, + $this->connection->quote_identifier( $identity_column ), + $table_identifier + ); + $stmt = $this->connection->query( $sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => array(), + ); + + $value = $stmt->fetchColumn(); + return false === $value ? '1' : (string) $value; + } + + /** + * Get the next AUTO_INCREMENT value from SQLite sequence state in tests. + * + * @param string $table_name Table name. + * @return string Next AUTO_INCREMENT value. + */ + private function get_sqlite_show_table_status_auto_increment_value( string $table_name ): string { + $sequence_table_sql = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence' LIMIT 1"; + $has_sequence_table = $this->connection->query( $sequence_table_sql )->fetchColumn(); + $this->last_postgresql_queries[] = array( + 'sql' => $sequence_table_sql, + 'params' => array(), + ); + + if ( false === $has_sequence_table ) { + return '1'; + } + + $stmt = $this->connection->query( + 'SELECT seq + 1 FROM sqlite_sequence WHERE name = ?', + array( $table_name ) + ); + $this->last_postgresql_queries[] = array( + 'sql' => 'SELECT seq + 1 FROM sqlite_sequence WHERE name = ?', + 'params' => array( $table_name ), + ); + + $value = $stmt->fetchColumn(); + return false === $value ? '1' : (string) $value; + } + + /** + * Filter SHOW TABLE STATUS rows with a parsed filter. + * + * @param array[] $rows SHOW TABLE STATUS rows. + * @param array $show_table_status_query Parsed SHOW TABLE STATUS options. + * @return array[] Filtered rows. + */ + private function filter_show_table_status_rows( array $rows, array $show_table_status_query ): array { + if ( 'all' === $show_table_status_query['filter_type'] ) { + return $rows; + } + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_table_status_query ): bool { + if ( 'like' === $show_table_status_query['filter_type'] ) { + return null !== $show_table_status_query['filter_pattern'] + && $this->matches_mysql_like_pattern( (string) $row['Name'], $show_table_status_query['filter_pattern'] ); + } + + if ( 'auto_increment_gt' === $show_table_status_query['filter_type'] ) { + return null !== $row['Auto_increment'] + && null !== $show_table_status_query['filter_threshold'] + && $this->is_unsigned_integer_string_greater_than( + (string) $row['Auto_increment'], + $show_table_status_query['filter_threshold'] + ); + } + + return 'auto_increment_is_null' === $show_table_status_query['filter_type'] + && null === $row['Auto_increment']; + } + ) + ); + } + + /** + * Compare two unsigned integer strings without losing precision. + * + * @param string $left Left integer. + * @param string $right Right integer. + * @return bool Whether left is greater than right. + */ + private function is_unsigned_integer_string_greater_than( string $left, string $right ): bool { + $left = ltrim( $left, '0' ); + $right = ltrim( $right, '0' ); + $left = '' === $left ? '0' : $left; + $right = '' === $right ? '0' : $right; + + if ( strlen( $left ) !== strlen( $right ) ) { + return strlen( $left ) > strlen( $right ); + } + + return strcmp( $left, $right ) > 0; + } + /** * Execute a MySQL SHOW VARIABLES statement from emulated session state. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index eab529cf4..f959dc5d9 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6583,6 +6583,163 @@ function ( $row ) { ); } + /** + * Tests SHOW TABLE STATUS returns MySQL-shaped catalog rows. + */ + public function test_show_table_status_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( 'SHOW TABLE STATUS' ); + + $this->assertCount( 2, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->Name ); + $this->assertSame( 'InnoDB', $tables[0]->Engine ); + $this->assertSame( '10', $tables[0]->Version ); + $this->assertSame( 'Dynamic', $tables[0]->Row_format ); + $this->assertSame( '0', $tables[0]->Rows ); + $this->assertSame( '0', $tables[0]->Avg_row_length ); + $this->assertSame( '0', $tables[0]->Data_length ); + $this->assertSame( '0', $tables[0]->Max_data_length ); + $this->assertSame( '0', $tables[0]->Index_length ); + $this->assertSame( '0', $tables[0]->Data_free ); + $this->assertSame( '1', $tables[0]->Auto_increment ); + $this->assertNull( $tables[0]->Create_time ); + $this->assertNull( $tables[0]->Update_time ); + $this->assertNull( $tables[0]->Check_time ); + $this->assertSame( $driver->get_collation(), $tables[0]->Collation ); + $this->assertNull( $tables[0]->Checksum ); + $this->assertSame( '', $tables[0]->Create_options ); + $this->assertSame( '', $tables[0]->Comment ); + $this->assertSame( 'wptests_posts', $tables[1]->Name ); + $this->assertNull( $tables[1]->Auto_increment ); + $this->assertSame( + $this->get_show_table_status_column_names(), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + foreach ( $driver->get_last_postgresql_queries() as $query ) { + $this->assertStringNotContainsString( 'SHOW TABLE STATUS', $query['sql'] ); + } + $this->assertStringContainsString( 'information_schema.tables', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests SHOW TABLE STATUS accepts current database qualification forms. + */ + public function test_show_table_status_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW TABLE STATUS FROM wptests', + 'SHOW TABLE STATUS IN `wptests`', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + foreach ( $driver->get_last_postgresql_queries() as $postgresql_query ) { + $this->assertStringNotContainsString( 'SHOW TABLE STATUS', $postgresql_query['sql'], $query ); + } + } + } + + /** + * Tests SHOW TABLE STATUS LIKE filters rows and hides internal metadata tables. + */ + public function test_show_table_status_like_filters_and_hides_internal_metadata_tables(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', '__wp_postgresql_mysql_column_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_index_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_charset_metadata', 'BASE TABLE'), + ('public', 'other_visible', 'BASE TABLE')" + ); + + $tables = $driver->query( "SHOW TABLE STATUS LIKE 'wptests_%'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $internal_tables = $driver->query( "SHOW TABLE STATUS LIKE '__wp_postgresql_mysql_%'" ); + + $this->assertSame( array(), $internal_tables ); + } + + /** + * Tests SHOW TABLE STATUS excludes temporary tables and views. + */ + public function test_show_table_status_excludes_temporary_tables_and_views(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->get_connection()->get_pdo()->exec( 'CREATE TEMPORARY TABLE wptests_temp (id INTEGER)' ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_temp', 'LOCAL TEMPORARY')" + ); + + $tables = $driver->query( 'SHOW TABLE STATUS' ); + $names = array_map( array( $this, 'get_show_table_status_row_name' ), $tables ); + + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), $names ); + $this->assertNotContains( 'wptests_view', $names ); + $this->assertNotContains( 'wptests_temp', $names ); + } + + /** + * Tests SHOW TABLE STATUS supports the scoped Auto_increment WHERE filters. + */ + public function test_show_table_status_where_filters_by_auto_increment(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_show_table_status_auto_increment_fixture( $driver ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE `Auto_increment` > 3' ); + + $this->assertSame( array( 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + $this->assertSame( '6', $tables[0]->Auto_increment ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment IS NULL' ); + + $this->assertSame( array( 'wptests_plain' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + $this->assertNull( $tables[0]->Auto_increment ); + } + + /** + * Tests unsupported SHOW TABLE STATUS WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_table_status_where_clause_does_not_reach_backend(): void { + $unsupported_queries = array( + "SHOW TABLE STATUS WHERE Name = 'wptests_options'", + 'SHOW TABLE STATUS WHERE `Auto_increment` >= 1', + 'SHOW TABLE STATUS FROM other_db', + ); + + foreach ( $unsupported_queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW TABLE STATUS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLE STATUS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests SHOW COLLATION returns MySQL-shaped static collation rows. */ @@ -8338,6 +8495,72 @@ private function install_site_health_table_count_fixture( WP_PostgreSQL_Driver $ $pdo->exec( 'INSERT INTO ' . $posts_table . ' (id) VALUES (1)' ); } + /** + * Get the SHOW TABLE STATUS result column names. + * + * @return string[] Column names. + */ + private function get_show_table_status_column_names(): array { + return array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ); + } + + /** + * Get the Name value from a SHOW TABLE STATUS row. + * + * @param object $row SHOW TABLE STATUS row. + * @return string Table name. + */ + private function get_show_table_status_row_name( $row ): string { + return $row->Name; + } + + /** + * Install SHOW TABLE STATUS AUTO_INCREMENT fixture rows. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_show_table_status_auto_increment_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + + $pdo->exec( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $pdo->exec( + "INSERT INTO wptests_posts (value) + VALUES ('a'), ('b'), ('c'), ('d'), ('e')" + ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_plain', 'BASE TABLE')" + ); + $pdo->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_posts', 'ID', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES'), + ('public', 'wptests_plain', 'id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'NO')" + ); + } + /** * Install a small information_schema fixture into the injected PDO. * From 017480a88f27d75b7a7d3ebc2111620a92a1bb35 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 14:32:59 +0000 Subject: [PATCH 115/142] Add PostgreSQL USE statement support --- .../postgresql/class-wp-postgresql-driver.php | 162 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 118 +++++++++++++ 2 files changed, 273 insertions(+), 7 deletions(-) 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 index d0a0997e7..e97afe3c8 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -59,7 +59,14 @@ class WP_PostgreSQL_Driver { private $connection; /** - * MySQL-facing database name. + * Configured main MySQL-facing database name. + * + * @var string + */ + private $main_db_name; + + /** + * Current MySQL-facing database name. * * @var string */ @@ -269,9 +276,10 @@ public function __construct( string $database, int $mysql_version = 80038 ) { - $this->connection = $connection; - $this->db_name = $database; - $this->client_info = $this->read_server_version(); + $this->connection = $connection; + $this->main_db_name = $database; + $this->db_name = $database; + $this->client_info = $this->read_server_version(); $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } @@ -403,6 +411,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + $use_database_name = $this->get_mysql_use_database_name( $query ); + if ( null !== $use_database_name ) { + return $this->execute_mysql_use_statement( $use_database_name ); + } + $transaction_control_query = $this->get_mysql_transaction_control_query( $query ); if ( null !== $transaction_control_query ) { return $this->execute_mysql_transaction_control_query( $transaction_control_query ); @@ -434,6 +447,16 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } + $database_function_column = $this->get_mysql_database_function_select_column( $query ); + if ( null !== $database_function_column ) { + return $this->set_mysql_static_show_result( + array( $database_function_column ), + array( array( $database_function_column => $this->db_name ) ), + $fetch_mode, + ...$fetch_mode_args + ); + } + $show_variables_query = $this->get_show_variables_query( $query ); if ( null !== $show_variables_query ) { return $this->execute_show_variables_query( $show_variables_query, $fetch_mode, ...$fetch_mode_args ); @@ -449,6 +472,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_show_databases_query( $show_databases_query, $fetch_mode, ...$fetch_mode_args ); } + if ( $this->should_reject_information_schema_backend_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + if ( $this->is_found_rows_query( $query ) ) { $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); $this->last_column_meta = array( @@ -3051,6 +3078,26 @@ private function handle_mysql_procedure_query( string $query, $fetch_mode = PDO: return null; } + /** + * Get the target database from a supported MySQL USE statement. + * + * @param string $query MySQL query. + * @return string|null Target database name, or null when this is not USE. + */ + private function get_mysql_use_database_name( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::USE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $database_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); + if ( null === $database_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { + throw new InvalidArgumentException( 'Unsupported USE statement.' ); + } + + return $database_name; + } + /** * Get the table name from a supported MySQL DESCRIBE/DESC statement. * @@ -3144,7 +3191,8 @@ private function get_show_table_status_query( string $query ): ?array { return null; } - $position = 3; + $position = 3; + $database_name = $this->db_name; if ( isset( $tokens[ $position ] ) && ( @@ -3153,13 +3201,17 @@ private function get_show_table_status_query( string $query ): ?array { ) ) { $database_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); - if ( null === $database_name || 0 !== strcasecmp( $database_name, $this->db_name ) ) { + if ( null === $database_name ) { throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); } $position += 2; } + if ( 0 !== strcasecmp( $database_name, $this->main_db_name ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { return array( 'filter_type' => 'all', @@ -4039,6 +4091,28 @@ private function execute_show_columns_query( string $schema_name, string $table_ return $this->last_result; } + /** + * Execute a supported MySQL USE statement in session state. + * + * @param string $database_name Requested MySQL-facing database name. + * @return int MySQL-compatible affected row count. + */ + private function execute_mysql_use_statement( string $database_name ): int { + if ( 0 === strcasecmp( $database_name, $this->main_db_name ) ) { + $this->db_name = $this->main_db_name; + $this->last_result = 0; + return $this->last_result; + } + + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + $this->db_name = 'information_schema'; + $this->last_result = 0; + return $this->last_result; + } + + throw new InvalidArgumentException( 'Unsupported USE statement.' ); + } + /** * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. * @@ -4564,7 +4638,7 @@ private function execute_show_databases_query( array $show_databases_query, $fet $rows = $this->filter_mysql_static_show_rows( array( array( 'Database' => 'information_schema' ), - array( 'Database' => $this->db_name ), + array( 'Database' => $this->main_db_name ), ), $show_databases_query ); @@ -12938,6 +13012,58 @@ private function translate_mysql_compatible_query( string $query ): ?string { return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); } + /** + * Check whether a query must fail closed while information_schema is selected. + * + * The PostgreSQL backend does not use MySQL database state for unqualified + * names. Without broad information_schema routing, table-scoped statements + * under USE information_schema would otherwise target public tables. + * + * @param string $query MySQL query. + * @return bool Whether the query should be rejected before backend execution. + */ + private function should_reject_information_schema_backend_query( string $query ): bool { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::ALTER_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::REPLACE_SYMBOL, + WP_MySQL_Lexer::TRUNCATE_SYMBOL, + WP_MySQL_Lexer::UPDATE_SYMBOL, + ), + true + ); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return true; + } + + return null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + 1, + $statement_end + ); + } + /** * Add explicit aliases to multi-expression COUNT aggregate projections. * @@ -18851,6 +18977,28 @@ private function get_sql_mode_select_variable( string $query ): ?string { return '@@' . $tokens[2]->get_value() . '.' . $tokens[4]->get_value(); } + /** + * Get the result column name from a supported SELECT DATABASE() query. + * + * @param string $query MySQL query. + * @return string|null Result column name, or null when unsupported. + */ + private function get_mysql_database_function_select_column( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DATABASE_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[3]->id + || ! $this->is_at_mysql_query_end( $tokens, 4 ) + ) { + return null; + } + + return $tokens[1]->get_value() . '()'; + } + /** * Check whether a query asks for MySQL FOUND_ROWS(). * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index f959dc5d9..dc7fbe84b 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6833,6 +6833,124 @@ public function test_show_databases_and_schemas_return_mysql_shaped_rows(): void $this->assertEquals( $databases, $schemas ); } + /** + * Tests USE accepts main database identifiers without backend execution. + */ + public function test_use_statement_accepts_main_database_identifiers_without_backend_execution(): void { + $queries = array( + 'USE wptests', + 'USE `wptests`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'}, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( 'DATABASE()', $driver->get_last_column_meta()[0]['name'], $query ); + } + } + + /** + * Tests USE information_schema changes current database state without backend execution. + */ + public function test_use_statement_accepts_information_schema_without_backend_execution(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'information_schema', $database[0]->{'DATABASE()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $tables = $driver->query( 'SHOW TABLES' ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'Tables_in_information_schema', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_information_schema ); + $this->assertStringNotContainsString( 'SHOW TABLES', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $databases = $driver->query( 'SHOW DATABASES' ); + + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => 'wptests' ), + ), + $databases + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported USE database names fail before backend execution. + */ + public function test_use_statement_rejects_unsupported_database_before_backend_execution(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'USE other_db' ); + $this->fail( 'Expected unsupported USE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported USE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'} ); + } + + /** + * Tests USE can switch back from information_schema to the main database. + */ + public function test_use_statement_switches_back_to_main_database(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'USE `wptests`' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'} ); + + $tables = $driver->query( 'SHOW TABLES' ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); + } + + /** + * Tests information_schema table SELECTs fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_selects_fail_closed(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'SELECT * FROM tables' ); + $this->fail( 'Expected information_schema SELECT to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests table administration statements return MySQL-shaped success rows. */ From 0f1d9b10aac8dcc1656f6242526e4cf58379c74b Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 14:43:16 +0000 Subject: [PATCH 116/142] Guard information_schema table handlers --- .../postgresql/class-wp-postgresql-driver.php | 84 ++++++++++++++++--- .../tests/WP_PostgreSQL_Driver_Tests.php | 65 +++++++++++++- 2 files changed, 132 insertions(+), 17 deletions(-) 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 index e97afe3c8..ce2ae07db 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -13032,25 +13032,56 @@ private function should_reject_information_schema_backend_query( string $query ) return false; } - if ( WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { - return in_array( + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + return $this->information_schema_select_has_table_reference( $tokens ); + } + + if ( WP_MySQL_Lexer::SHOW_SYMBOL === $tokens[0]->id ) { + return $this->is_information_schema_table_scoped_show_query( $tokens ); + } + + if ( + in_array( $tokens[0]->id, array( - WP_MySQL_Lexer::ALTER_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::REPLACE_SYMBOL, - WP_MySQL_Lexer::TRUNCATE_SYMBOL, - WP_MySQL_Lexer::UPDATE_SYMBOL, + WP_MySQL_Lexer::EXPLAIN_SYMBOL, + WP_MySQL_Lexer::WITH_SYMBOL, ), true - ); + ) + ) { + return true; } + return in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::ALTER_SYMBOL, + WP_MySQL_Lexer::ANALYZE_SYMBOL, + WP_MySQL_Lexer::CHECK_SYMBOL, + WP_MySQL_Lexer::CREATE_SYMBOL, + WP_MySQL_Lexer::DESCRIBE_SYMBOL, + WP_MySQL_Lexer::DELETE_SYMBOL, + WP_MySQL_Lexer::DESC_SYMBOL, + WP_MySQL_Lexer::DROP_SYMBOL, + WP_MySQL_Lexer::INSERT_SYMBOL, + WP_MySQL_Lexer::OPTIMIZE_SYMBOL, + WP_MySQL_Lexer::REPLACE_SYMBOL, + WP_MySQL_Lexer::REPAIR_SYMBOL, + WP_MySQL_Lexer::TRUNCATE_SYMBOL, + WP_MySQL_Lexer::UPDATE_SYMBOL, + ), + true + ); + } + + /** + * Check whether a SELECT under USE information_schema reaches a table source. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the SELECT should be rejected. + */ + private function information_schema_select_has_table_reference( array $tokens ): bool { $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); if ( null === $statement_end ) { return true; @@ -13064,6 +13095,33 @@ private function should_reject_information_schema_backend_query( string $query ) ); } + /** + * Check whether a SHOW query is table-scoped under USE information_schema. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the SHOW query should be rejected. + */ + private function is_information_schema_table_scoped_show_query( array $tokens ): bool { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + + return isset( $tokens[1] ) + && ( + WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::INDEXES_SYMBOL === $tokens[1]->id + ); + } + /** * Add explicit aliases to multi-expression COUNT aggregate projections. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index dc7fbe84b..635328f88 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6935,16 +6935,73 @@ public function test_use_statement_switches_back_to_main_database(): void { } /** - * Tests information_schema table SELECTs fail closed until routing is implemented. + * Tests information_schema table reads fail closed until routing is implemented. */ - public function test_use_statement_information_schema_table_selects_fail_closed(): void { + public function test_use_statement_information_schema_table_reads_fail_closed(): void { $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $queries = array( + 'SELECT * FROM wptests_options', + 'WITH q AS (SELECT * FROM wptests_options) SELECT * FROM q', + 'EXPLAIN SELECT * FROM wptests_options', + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema table read to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests information_schema table metadata handlers fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_metadata_handlers_fail_closed(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'SHOW COLUMNS FROM wptests_options' ); + $this->fail( 'Expected information_schema SHOW COLUMNS to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $index_driver = $this->create_show_index_driver(); + + $this->assertSame( 0, $index_driver->query( 'USE information_schema' ) ); + + try { + $index_driver->query( 'SHOW INDEX FROM wptests_options' ); + $this->fail( 'Expected information_schema SHOW INDEX to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests information_schema table administration handlers fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_administration_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); try { - $driver->query( 'SELECT * FROM tables' ); - $this->fail( 'Expected information_schema SELECT to throw.' ); + $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() ); From cc2f9d3dba6d622295b20bb43784e1e7ea01f339 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 14:55:35 +0000 Subject: [PATCH 117/142] Add PostgreSQL SHOW CREATE TABLE support --- .../postgresql/class-wp-postgresql-driver.php | 394 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 147 +++++++ 2 files changed, 541 insertions(+) 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 index ce2ae07db..f9bbb1ed2 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -550,6 +550,15 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo ); } + $show_create_table_query = $this->get_show_create_table_query( $query ); + if ( null !== $show_create_table_query ) { + return $this->execute_show_create_table_query( + $show_create_table_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + $show_columns_query = $this->get_show_columns_query( $query ); if ( null !== $show_columns_query ) { return $this->execute_show_columns_query( @@ -3295,6 +3304,84 @@ private function get_show_table_status_where_filter( array $tokens, int $positio return null; } + /** + * Parse a supported MySQL SHOW CREATE TABLE statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string}|null SHOW CREATE TABLE options, or null when this is not SHOW CREATE TABLE. + */ + private function get_show_create_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[2]->id ) { + return null; + } + + $table_reference = $this->get_show_create_table_reference( $tokens, 3 ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $table_reference['position'] ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + + if ( null !== $table_reference['schema'] ) { + if ( 0 === strcasecmp( $table_reference['schema'], 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( + 0 !== strcasecmp( $table_reference['schema'], $this->main_db_name ) + && 0 !== strcasecmp( $table_reference['schema'], 'public' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + } + + return array( + 'schema' => 'public', + 'table' => $table_reference['table'], + ); + } + + /** + * Parse a SHOW CREATE TABLE table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @return array{schema: string|null, table: string, position: int}|null Parsed reference, or null when unsupported. + */ + private function get_show_create_table_reference( array $tokens, int $position ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + 'position' => $position, + ); + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + 'position' => $position + 2, + ); + } + /** * Check whether a token is an unsigned integer literal. * @@ -4211,6 +4298,305 @@ private function execute_show_table_status_query( array $show_table_status_query ); } + /** + * Execute a MySQL SHOW CREATE TABLE statement from stored MySQL schema metadata. + * + * @param array $show_create_table_query SHOW CREATE TABLE options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW CREATE TABLE result rows. + */ + private function execute_show_create_table_query( array $show_create_table_query, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $table_name = $show_create_table_query['table']; + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( + $show_create_table_query['schema'], + $table_name + ); + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_create_table', + $fetch_mode, + array( $resolved_schema, $table_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $columns = $this->get_show_create_table_column_metadata_rows( $resolved_schema, $table_name ); + if ( empty( $columns ) ) { + return $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $indexes = $this->get_show_create_table_index_metadata_rows( $resolved_schema, $table_name ); + $create_statement = $this->get_mysql_create_table_statement_from_metadata( $table_name, $columns, $indexes ); + $rows = array( + array( + 'Table' => $table_name, + 'Create Table' => $create_statement, + ), + ); + + $result = $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $result; + } + + /** + * Get column metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_show_create_table_column_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT column_name, ordinal_position, column_type, character_set_name, collation_name, is_nullable, column_default, extra + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get index metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] Index metadata rows. + */ + private function get_show_create_table_index_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT key_name, index_ordinal, seq_in_index, column_name, non_unique, index_type, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY + key_name = \'PRIMARY\' DESC, + non_unique = \'0\' DESC, + index_type = \'SPATIAL\' DESC, + index_type = \'BTREE\' DESC, + index_type = \'FULLTEXT\' DESC, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Build a MySQL CREATE TABLE statement from stored MySQL metadata rows. + * + * @param string $table_name Table name. + * @param array[] $columns Column metadata rows. + * @param array[] $indexes Index metadata rows. + * @return string MySQL-compatible CREATE TABLE statement. + */ + private function get_mysql_create_table_statement_from_metadata( string $table_name, array $columns, array $indexes ): string { + $definitions = array(); + foreach ( $columns as $column ) { + $definitions[] = $this->get_mysql_create_table_column_definition_from_metadata( $column ); + } + + foreach ( $this->group_show_create_table_index_metadata_rows( $indexes ) as $index ) { + $definitions[] = $this->get_mysql_create_table_index_definition_from_metadata( $index ); + } + + $collation = $this->get_mysql_create_table_collation_from_metadata( $columns ); + $charset = $this->get_mysql_charset_from_collation( $collation ); + + return sprintf( + "CREATE TABLE %s (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=%s COLLATE=%s", + $this->quote_mysql_identifier( $table_name ), + implode( ",\n", $definitions ), + $charset, + $collation + ); + } + + /** + * Build one MySQL column definition from stored metadata. + * + * @param array $column Column metadata row. + * @return string Column definition SQL. + */ + private function get_mysql_create_table_column_definition_from_metadata( array $column ): string { + $sql = sprintf( + ' %s %s', + $this->quote_mysql_identifier( (string) $column['column_name'] ), + (string) $column['column_type'] + ); + + if ( 'NO' === strtoupper( (string) $column['is_nullable'] ) ) { + $sql .= ' NOT NULL'; + } + + if ( false !== stripos( (string) $column['extra'], 'auto_increment' ) ) { + $sql .= ' AUTO_INCREMENT'; + } + + if ( null !== $column['column_default'] ) { + $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( (string) $column['column_default'] ); + } elseif ( 'NO' !== strtoupper( (string) $column['is_nullable'] ) ) { + $sql .= ' DEFAULT NULL'; + } + + return $sql; + } + + /** + * Group stored index metadata rows by index name. + * + * @param array[] $indexes Index metadata rows. + * @return array[] Grouped index metadata rows. + */ + private function group_show_create_table_index_metadata_rows( array $indexes ): array { + $grouped = array(); + foreach ( $indexes as $index ) { + $key_name = (string) $index['key_name']; + if ( ! isset( $grouped[ $key_name ] ) ) { + $grouped[ $key_name ] = array(); + } + + $grouped[ $key_name ][] = $index; + } + + return array_values( $grouped ); + } + + /** + * Build one MySQL key definition from grouped stored metadata. + * + * @param array[] $index Grouped index metadata rows. + * @return string Key definition SQL. + */ + private function get_mysql_create_table_index_definition_from_metadata( array $index ): string { + $first = $index[0]; + if ( 'PRIMARY' === strtoupper( (string) $first['key_name'] ) ) { + return sprintf( + ' PRIMARY KEY (%s)', + implode( ', ', $this->get_mysql_create_table_index_column_definitions( $index ) ) + ); + } + + return sprintf( + ' %s%sKEY %s (%s)', + '0' === (string) $first['non_unique'] ? 'UNIQUE ' : '', + 'BTREE' !== strtoupper( (string) $first['index_type'] ) ? strtoupper( (string) $first['index_type'] ) . ' ' : '', + $this->quote_mysql_identifier( (string) $first['key_name'] ), + implode( ', ', $this->get_mysql_create_table_index_column_definitions( $index ) ) + ); + } + + /** + * Build quoted MySQL key part definitions from grouped index metadata rows. + * + * @param array[] $index Grouped index metadata rows. + * @return string[] Key part definitions. + */ + private function get_mysql_create_table_index_column_definitions( array $index ): array { + $columns = array(); + foreach ( $index as $column ) { + $definition = $this->quote_mysql_identifier( (string) $column['column_name'] ); + if ( null !== $column['sub_part'] ) { + $definition .= sprintf( '(%d)', (int) $column['sub_part'] ); + } + + $columns[] = $definition; + } + + return $columns; + } + + /** + * Get a table collation for SHOW CREATE TABLE from column metadata. + * + * @param array[] $columns Column metadata rows. + * @return string MySQL collation. + */ + private function get_mysql_create_table_collation_from_metadata( array $columns ): string { + foreach ( $columns as $column ) { + if ( ! empty( $column['collation_name'] ) ) { + return (string) $column['collation_name']; + } + } + + return $this->collation; + } + + /** + * Get a MySQL charset name from a collation. + * + * @param string $collation MySQL collation. + * @return string MySQL charset. + */ + private function get_mysql_charset_from_collation( string $collation ): string { + $underscore_position = strpos( $collation, '_' ); + if ( false === $underscore_position ) { + return $collation; + } + + return substr( $collation, 0, $underscore_position ); + } + + /** + * Quote an identifier for use in a MySQL query. + * + * @param string $identifier Unquoted identifier value. + * @return string Quoted identifier. + */ + private function quote_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + + /** + * Quote a MySQL UTF-8 string literal for SHOW CREATE TABLE output. + * + * @param string $literal Literal value. + * @return string Quoted literal. + */ + private function quote_mysql_utf8_string_literal( string $literal ): string { + $backslash = chr( 92 ); + $replacements = array( + "'" => "''", + $backslash => $backslash . $backslash, + chr( 0 ) => $backslash . '0', + chr( 10 ) => $backslash . 'n', + chr( 13 ) => $backslash . 'r', + ); + + return "'" . strtr( $literal, $replacements ) . "'"; + } + /** * Get base table rows used by SHOW TABLE STATUS. * @@ -13115,6 +13501,14 @@ private function is_information_schema_table_scoped_show_query( array $tokens ): return true; } + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id + ) { + return true; + } + return isset( $tokens[1] ) && ( WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[1]->id diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 635328f88..22837bfec 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6740,6 +6740,103 @@ public function test_unsupported_show_table_status_where_clause_does_not_reach_b } } + /** + * Tests SHOW CREATE TABLE returns MySQL-shaped metadata rows. + */ + public function test_show_create_table_returns_mysql_shaped_metadata_result(): void { + $driver = $this->create_driver(); + $this->install_show_create_table_fixture( $driver, 'wptests_show_create' ); + + $tables = $driver->query( 'SHOW CREATE TABLE wptests_show_create' ); + + $this->assertCount( 1, $tables ); + $this->assertSame( 'wptests_show_create', $tables[0]->Table ); + $this->assertSame( array( 'Table', 'Create Table' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $create_table = $tables[0]->{'Create Table'}; + $this->assertStringStartsWith( "CREATE TABLE `wptests_show_create` (\n", $create_table ); + $this->assertStringContainsString( ' `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $create_table ); + $this->assertStringContainsString( " `title` varchar(191) NOT NULL DEFAULT ''", $create_table ); + $this->assertStringContainsString( ' `description` text DEFAULT NULL', $create_table ); + $this->assertStringContainsString( " `status` varchar(20) NOT NULL DEFAULT 'draft'", $create_table ); + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `title` (`title`)', $create_table ); + $this->assertStringContainsString( ' KEY `status` (`status`)', $create_table ); + $this->assertStringContainsString( ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', $create_table ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE, $queries[0]['sql'] ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE, $queries[1]['sql'] ); + foreach ( $queries as $query ) { + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $query['sql'] ); + } + + $assoc = $driver->query( 'SHOW CREATE TABLE wptests_show_create', PDO::FETCH_ASSOC ); + $this->assertSame( 'wptests_show_create', $assoc[0]['Table'] ); + $this->assertSame( $create_table, $assoc[0]['Create Table'] ); + + $num = $driver->query( 'SHOW CREATE TABLE wptests_show_create', PDO::FETCH_NUM ); + $this->assertSame( array( 'wptests_show_create', $create_table ), $num[0] ); + } + + /** + * Tests SHOW CREATE TABLE accepts backtick and main database qualifications. + */ + public function test_show_create_table_accepts_backtick_and_main_database_qualification_forms(): void { + $queries = array( + 'SHOW CREATE TABLE `wptests_show_create_forms`', + 'SHOW CREATE TABLE wptests.wptests_show_create_forms', + 'SHOW CREATE TABLE `wptests`.`wptests_show_create_forms`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_show_create_table_fixture( $driver, 'wptests_show_create_forms' ); + + $tables = $driver->query( $query ); + + $this->assertCount( 1, $tables, $query ); + $this->assertSame( 'wptests_show_create_forms', $tables[0]->Table, $query ); + $this->assertStringStartsWith( "CREATE TABLE `wptests_show_create_forms` (\n", $tables[0]->{'Create Table'}, $query ); + foreach ( $driver->get_last_postgresql_queries() as $postgresql_query ) { + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $postgresql_query['sql'], $query ); + } + } + } + + /** + * Tests SHOW CREATE TABLE for a missing table returns an empty metadata result. + */ + public function test_show_create_table_missing_table_returns_empty_result(): void { + $driver = $this->create_driver(); + + $tables = $driver->query( 'SHOW CREATE TABLE wptests_missing' ); + + $this->assertSame( array(), $tables ); + $this->assertSame( array( 'Table', 'Create Table' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE, $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $queries[0]['sql'] ); + } + + /** + * Tests SHOW CREATE TABLE information_schema targets fail closed. + */ + public function test_show_create_table_information_schema_target_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SHOW CREATE TABLE information_schema.tables' ); + $this->fail( 'Expected information_schema SHOW CREATE TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests SHOW COLLATION returns MySQL-shaped static collation rows. */ @@ -6988,6 +7085,19 @@ public function test_use_statement_information_schema_table_metadata_handlers_fa $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); } + + $show_create_driver = $this->create_driver(); + $this->install_show_create_table_fixture( $show_create_driver, 'wptests_options' ); + + $this->assertSame( 0, $show_create_driver->query( 'USE information_schema' ) ); + + try { + $show_create_driver->query( 'SHOW CREATE TABLE wptests_options' ); + $this->fail( 'Expected information_schema SHOW CREATE TABLE to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $show_create_driver->get_last_postgresql_queries() ); + } } /** @@ -8736,6 +8846,43 @@ private function install_show_table_status_auto_increment_fixture( WP_PostgreSQL ); } + /** + * Install a table shape covered by SHOW CREATE TABLE reconstruction tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + */ + private function install_show_create_table_fixture( WP_PostgreSQL_Driver $driver, string $table_name ): void { + $driver->query( + sprintf( + "CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL, + title varchar(191) NOT NULL DEFAULT '', + description text, + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id), + UNIQUE KEY title (title), + KEY status (status) + )", + $table_name + ) + ); + $driver->store_mysql_schema_metadata( + sprintf( + "CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) NOT NULL DEFAULT '', + description text, + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id), + UNIQUE KEY title (title), + KEY status (status) + )", + $table_name + ) + ); + } + /** * Install a small information_schema fixture into the injected PDO. * From 86b138ad5afc98eda4d8eaadff3040086f111910 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:11:08 +0000 Subject: [PATCH 118/142] Add PostgreSQL SHOW GRANTS support --- .../postgresql/class-wp-postgresql-driver.php | 82 ++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 143 ++++++++++++++++++ 2 files changed, 225 insertions(+) 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 index f9bbb1ed2..99addaebd 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -19,6 +19,13 @@ class WP_PostgreSQL_Driver { const DEFAULT_MYSQL_CHARSET = 'utf8mb4'; const DEFAULT_MYSQL_COLLATION = 'utf8mb4_unicode_ci'; + 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. */ @@ -472,6 +479,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_show_databases_query( $show_databases_query, $fetch_mode, ...$fetch_mode_args ); } + $show_grants_query = $this->get_show_grants_query( $query ); + if ( null !== $show_grants_query ) { + return $this->execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ); + } + if ( $this->should_reject_information_schema_backend_query( $query ) ) { throw new InvalidArgumentException( 'Unsupported information_schema query.' ); } @@ -3511,6 +3523,50 @@ private function get_show_databases_query( string $query ): ?array { return $filter; } + /** + * Parse a supported MySQL SHOW GRANTS statement. + * + * @param string $query MySQL query. + * @return array{}|null Empty options array, or null when this is not SHOW GRANTS. + */ + private function get_show_grants_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::GRANTS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( $this->is_at_mysql_query_end( $tokens, 2 ) ) { + return array(); + } + + if ( + ! isset( $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CURRENT_USER_SYMBOL !== $tokens[3]->id + ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + if ( $this->is_at_mysql_query_end( $tokens, 4 ) ) { + return array(); + } + + if ( + isset( $tokens[4], $tokens[5] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[4]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[5]->id + && $this->is_at_mysql_query_end( $tokens, 6 ) + ) { + return array(); + } + + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + /** * Parse optional LIKE or simple WHERE filters for static SHOW result sets. * @@ -5037,6 +5093,31 @@ private function execute_show_databases_query( array $show_databases_query, $fet ); } + /** + * Execute a MySQL SHOW GRANTS statement from static MySQL-compatible metadata. + * + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW GRANTS result rows. + */ + private function execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ) { + $this->last_found_rows = 1; + + $result = $this->set_mysql_static_show_result( + array( self::MYSQL_SHOW_GRANTS_COLUMN ), + array( + array( + self::MYSQL_SHOW_GRANTS_COLUMN => self::MYSQL_SHOW_GRANTS_VALUE, + ), + ), + $fetch_mode, + ...$fetch_mode_args + ); + $this->last_column_meta[0]['len'] = 4096; + + return $result; + } + /** * Filter static SHOW rows with a parsed MySQL LIKE or WHERE filter. * @@ -5096,6 +5177,7 @@ private function set_mysql_static_show_result( array $columns, array $rows, $fet 'native_type' => 'string', ); } + $this->last_column_count = count( $this->last_column_meta ); if ( PDO::FETCH_ASSOC === $fetch_mode ) { $this->last_result = $rows; diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 22837bfec..812f051bf 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6837,6 +6837,124 @@ public function test_show_create_table_information_schema_target_fails_closed(): } } + /** + * Tests SHOW GRANTS returns a static MySQL-shaped grants row. + */ + public function test_show_grants_returns_static_mysql_shaped_row(): void { + $driver = $this->create_driver(); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( 'SHOW GRANTS', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 1, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Grants for root@%', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Grants for root@%', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 4096, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + + $assoc = $driver->query( 'SHOW GRANTS', PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'Grants for root@%' => $this->get_show_grants_expected_value(), + ), + ), + $assoc + ); + + $num = $driver->query( 'SHOW GRANTS', PDO::FETCH_NUM ); + $this->assertSame( array( array( $this->get_show_grants_expected_value() ) ), $num ); + } + + /** + * Tests supported SHOW GRANTS CURRENT_USER forms return the static grants row. + */ + public function test_show_grants_current_user_forms_return_static_row(): void { + $queries = array( + 'SHOW GRANTS FOR current_user();', + 'SHOW GRANTS FOR CURRENT_USER', + 'sHoW gRaNtS FoR CuRrEnT_UsEr()', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $grants = $driver->query( $query ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants, $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array( 'Grants for root@%' ), array_column( $driver->get_last_column_meta(), 'name' ), $query ); + } + } + + /** + * Tests SHOW GRANTS updates FOUND_ROWS() accounting. + */ + public function test_show_grants_sets_found_rows_to_one(): void { + $driver = $this->create_driver(); + + $driver->query( 'SHOW GRANTS' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW GRANTS is not table-scoped under USE information_schema. + */ + public function test_show_grants_after_use_information_schema_returns_static_row(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'information_schema', $driver->get_last_column_meta()[0]['mysqli:db'] ); + } + + /** + * Tests unsupported SHOW GRANTS syntax fails before backend execution. + */ + public function test_unsupported_show_grants_syntax_fails_closed(): void { + $queries = array( + 'SHOW GRANTS FOR root', + 'SHOW GRANTS USING role1', + 'SHOW GRANTS FOR CURRENT_USER() USING role1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW GRANTS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW GRANTS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests SHOW COLLATION returns MySQL-shaped static collation rows. */ @@ -8692,6 +8810,31 @@ private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, st return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Get the expected SHOW GRANTS result rows. + * + * @return object[] Expected result rows. + */ + private function get_show_grants_expected_result(): array { + return array( + (object) array( + 'Grants for root@%' => $this->get_show_grants_expected_value(), + ), + ); + } + + /** + * Get the expected static SHOW GRANTS row value. + * + * @return string Expected grant text. + */ + private function get_show_grants_expected_value(): string { + return 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, ' . + 'PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, ' . + 'EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, ' . + 'CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION'; + } + /** * Get a fetch row class name whose clone operation is not publicly callable. * From 8b1c14506258d6b635c4c923161f194a81d3387b Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:21:14 +0000 Subject: [PATCH 119/142] Add PostgreSQL index hint translation --- .../postgresql/class-wp-postgresql-driver.php | 231 ++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 101 ++++++++ 2 files changed, 332 insertions(+) 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 index 99addaebd..8f0501df0 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -706,6 +706,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } } + if ( $this->contains_mysql_index_hint_syntax( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL index hint syntax.' ); + } + $stmt = $this->connection->query( $query ); $this->last_postgresql_queries[] = array( 'sql' => $query, @@ -17349,6 +17353,9 @@ private function translate_mysql_token_sequence_to_postgresql( array $tokens, in $token = $tokens[ $i ]; $fragment_token_id = $token->id; $translated_fragment = $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $i, $end ); + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_index_hint_to_postgresql( $tokens, $i, $end ); + } if ( null === $translated_fragment ) { $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); } @@ -17481,6 +17488,209 @@ private function is_mysql_dual_table_reference_boundary_token( WP_MySQL_Token $t ); } + /** + * Erase supported MySQL optimizer index hints. + * + * PostgreSQL has no equivalent for MySQL's USE/FORCE/IGNORE INDEX hints. + * Keep this bounded to the parsed hint clause so surrounding aliases, joins, + * predicates, grouping, ordering, and limits are still rendered normally. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Hint keyword token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_index_hint_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_index_hint_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + return array( + 'sql' => '', + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['end'] - 1, + ); + } + + /** + * Get token bounds for a supported MySQL optimizer index hint. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Hint keyword token position. + * @param int $end Final token position, exclusive. + * @return array{end: int}|null Hint bounds, or null when unsupported. + */ + private function get_mysql_index_hint_bounds( array $tokens, int $position, int $end ): ?array { + if ( ! $this->is_mysql_index_hint_marker( $tokens, $position, $end ) ) { + return null; + } + + $hint_action = $tokens[ $position ]->id; + $position += 2; + + if ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->get_mysql_index_hint_scope_end( $tokens, $position, $end ); + if ( null === $position ) { + return null; + } + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $allow_empty_list = WP_MySQL_Lexer::USE_SYMBOL === $hint_action; + if ( ! $this->is_mysql_index_hint_identifier_list( $tokens, $position + 1, $after_close - 1, $allow_empty_list ) ) { + return null; + } + + return array( + 'end' => $after_close, + ); + } + + /** + * Check whether tokens at a position begin a MySQL optimizer index hint. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an index hint marker is present. + */ + private function is_mysql_index_hint_marker( array $tokens, int $position, int $end ): bool { + return $position + 1 < $end + && $this->is_mysql_index_hint_action_token( $tokens[ $position ] ?? null ) + && $this->is_mysql_index_hint_type_token( $tokens[ $position + 1 ] ?? null ); + } + + /** + * Check whether a token starts a MySQL optimizer index hint. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is USE, FORCE, or IGNORE. + */ + private function is_mysql_index_hint_action_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return in_array( + $token->id, + array( + WP_MySQL_Lexer::FORCE_SYMBOL, + WP_MySQL_Lexer::IGNORE_SYMBOL, + WP_MySQL_Lexer::USE_SYMBOL, + ), + true + ); + } + + /** + * Check whether a token names the hinted object type. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is INDEX or KEY. + */ + private function is_mysql_index_hint_type_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return WP_MySQL_Lexer::INDEX_SYMBOL === $token->id || WP_MySQL_Lexer::KEY_SYMBOL === $token->id; + } + + /** + * Get the position after a supported MySQL optimizer index hint scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position FOR token position. + * @param int $end Final token position, exclusive. + * @return int|null Position after scope tokens, or null when unsupported. + */ + private function get_mysql_index_hint_scope_end( array $tokens, int $position, int $end ): ?int { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) || $position + 1 >= $end ) { + return null; + } + + if ( WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position + 1 ]->id ) { + return $position + 2; + } + + if ( + isset( $tokens[ $position + 2 ] ) + && $position + 2 < $end + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $position + 2 ]->id + && ( + WP_MySQL_Lexer::GROUP_SYMBOL === $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position + 1 ]->id + ) + ) { + return $position + 3; + } + + return null; + } + + /** + * Check whether a token range is a supported MySQL index-name list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First list token position. + * @param int $end Final list token position, exclusive. + * @param bool $allow_empty Whether an empty list is valid. + * @return bool Whether the token range is a supported index-name list. + */ + private function is_mysql_index_hint_identifier_list( array $tokens, int $start, int $end, bool $allow_empty ): bool { + if ( $start === $end ) { + return $allow_empty; + } + + $expect_identifier = true; + for ( $i = $start; $i < $end; $i++ ) { + if ( $expect_identifier ) { + if ( ! $this->is_mysql_index_hint_identifier_token( $tokens[ $i ] ?? null ) ) { + return false; + } + + $expect_identifier = false; + continue; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { + return false; + } + + $expect_identifier = true; + } + + return ! $expect_identifier; + } + + /** + * Check whether a token can name an index in a MySQL optimizer hint. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is a supported index identifier. + */ + private function is_mysql_index_hint_identifier_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return WP_MySQL_Lexer::PRIMARY_SYMBOL === $token->id + || null !== $this->get_mysql_identifier_token_value( $token ); + } + /** * Translate MySQL LIMIT offset,count syntax to PostgreSQL LIMIT count OFFSET offset. * @@ -19307,6 +19517,10 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return true; } + if ( null !== $this->get_mysql_index_hint_bounds( $tokens, $i, $end ) ) { + return true; + } + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'field' ) ) { return true; } @@ -19371,6 +19585,23 @@ private function needs_mysql_compatible_rewrite( array $tokens, int $start, int return false; } + /** + * Check whether a query still contains raw MySQL optimizer index hint syntax. + * + * @param string $query SQL query. + * @return bool Whether raw MySQL index hint syntax remains. + */ + private function contains_mysql_index_hint_syntax( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + for ( $i = 0; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( $this->is_mysql_index_hint_marker( $tokens, $i, count( $tokens ) ) ) { + return true; + } + } + + return false; + } + /** * Check whether a query is a supported MySQL CREATE TABLE statement. * diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 812f051bf..208251abe 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -48,6 +48,68 @@ public function test_query_returns_rows_and_metadata(): void { $this->assertArrayHasKey( 'mysqli:charsetnr', $column_meta[0] ); } + /** + * Tests MySQL optimizer index hints are removed before PostgreSQL execution. + */ + public function test_select_index_hints_are_removed_before_postgresql_execution(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + $queries = array( + 'SELECT * FROM t USE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t USE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t FORCE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t FORCE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t IGNORE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t IGNORE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t USE INDEX FOR JOIN (i) JOIN j ON t.id = j.t_id' => 'SELECT * FROM t JOIN j ON t.id = j.t_id', + 'SELECT * FROM t USE INDEX FOR ORDER BY (i) ORDER BY id DESC' => 'SELECT * FROM t ORDER BY id DESC', + 'SELECT * FROM t USE INDEX FOR GROUP BY (i) GROUP BY id HAVING id = 1' => 'SELECT * FROM t GROUP BY id HAVING id = 1', + 'SELECT * FROM `t` USE INDEX (i) USE INDEX FOR JOIN (j) USE KEY FOR ORDER BY (o) IGNORE INDEX FOR GROUP BY (g) JOIN j ON t.id = j.t_id WHERE id = 1 GROUP BY id HAVING id = 1 ORDER BY id DESC' => 'SELECT * FROM "t" JOIN j ON t.id = j.t_id WHERE id = 1 GROUP BY id HAVING id = 1 ORDER BY id DESC', + ); + + foreach ( $queries as $mysql_query => $postgresql_sql ) { + $rows = $driver->query( $mysql_query ); + + $this->assertSame( array(), $rows, $mysql_query ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertSame( $postgresql_sql, $sql, $mysql_query ); + $this->assert_postgresql_sql_omits_mysql_index_hints( $sql ); + } + } + + /** + * Tests quoted table and index identifiers keep aliases while index hints are removed. + */ + public function test_select_index_hints_preserve_quoted_table_and_alias(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + $driver->query( "INSERT INTO t (id, value) VALUES (1, 'first')" ); + + $rows = $driver->query( 'SELECT tt.id FROM `t` AS tt USE INDEX (`ix_t_id`) WHERE tt.id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertSame( 'SELECT tt.id FROM "t" AS tt WHERE tt.id = 1', $sql ); + $this->assert_postgresql_sql_omits_mysql_index_hints( $sql ); + } + + /** + * Tests malformed MySQL index hints are not sent raw to PostgreSQL. + */ + public function test_malformed_select_index_hint_is_rejected_before_postgresql_execution(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + try { + $driver->query( 'SELECT * FROM t USE INDEX' ); + $this->fail( 'Malformed MySQL index hint was not rejected.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported MySQL index hint syntax.', $exception->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests result column metadata is normalized only when requested. */ @@ -8176,6 +8238,45 @@ private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Dri return new WP_PostgreSQL_Driver( $connection, $db_name ); } + /** + * Creates a PostgreSQL driver with tables used by index hint translation tests. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_index_hint_tables(): WP_PostgreSQL_Driver { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER, value TEXT)' ); + $driver->query( 'CREATE TABLE j (t_id INTEGER, value TEXT)' ); + + return $driver; + } + + /** + * Get the last single PostgreSQL SQL statement executed by a driver. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @return string Last PostgreSQL SQL. + */ + private function get_last_single_postgresql_sql( WP_PostgreSQL_Driver $driver ): string { + $queries = $driver->get_last_postgresql_queries(); + + $this->assertCount( 1, $queries ); + return $queries[0]['sql']; + } + + /** + * Assert a PostgreSQL SQL string does not contain raw MySQL index hints. + * + * @param string $sql PostgreSQL SQL. + */ + private function assert_postgresql_sql_omits_mysql_index_hints( string $sql ): void { + $uppercase_sql = strtoupper( $sql ); + foreach ( array( 'USE INDEX', 'USE KEY', 'FORCE INDEX', 'FORCE KEY', 'IGNORE INDEX', 'IGNORE KEY' ) as $hint ) { + $this->assertStringNotContainsString( $hint, $uppercase_sql ); + } + } + /** * Creates a SQLite-backed driver whose connection reports a stale insert ID. * From 428cb77eb863eedfbbabbfaa5e9561ab21836e34 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:30:32 +0000 Subject: [PATCH 120/142] Add PostgreSQL SHOW KEYS support --- .../postgresql/class-wp-postgresql-driver.php | 18 +++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 80 +++++++++++++++++-- 2 files changed, 84 insertions(+), 14 deletions(-) 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 index 8f0501df0..b9d404061 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3810,10 +3810,10 @@ private function get_show_columns_table_reference( array $tokens, int $position } /** - * Parse a supported MySQL SHOW INDEX/SHOW INDEXES statement. + * Parse a supported MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement. * * @param string $query MySQL query. - * @return array{table: string, key_name: string|null}|null SHOW INDEX options, or null when unsupported. + * @return array{table: string, key_name: string|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. */ private function get_show_index_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -3824,17 +3824,18 @@ private function get_show_index_query( string $query ): ?array { if ( WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id && WP_MySQL_Lexer::INDEXES_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::KEYS_SYMBOL !== $tokens[1]->id ) { return null; } if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id ) { - return null; + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } $table_name = $this->get_mysql_identifier_token_value( $tokens[3] ?? null ); if ( null === $table_name ) { - return null; + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } $position = 4; @@ -3851,7 +3852,7 @@ private function get_show_index_query( string $query ): ?array { && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 3 ]->id ) ) { - return null; + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } $key_name = $tokens[ $position + 3 ]->get_value(); @@ -3859,7 +3860,7 @@ private function get_show_index_query( string $query ): ?array { } if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { - return null; + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } return array( @@ -5309,7 +5310,7 @@ private function get_default_mysql_collation_for_charset( string $charset ): str } /** - * Execute a MySQL SHOW INDEX/SHOW INDEXES statement through PostgreSQL catalogs. + * Execute a MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement through PostgreSQL catalogs. * * @param string $table_name Table name. * @param string|null $key_name Optional MySQL Key_name filter. @@ -5971,7 +5972,7 @@ private function get_show_columns_catalog_query( bool $is_full ): string { } /** - * Get the PostgreSQL catalog query backing MySQL SHOW INDEX/SHOW INDEXES. + * Get the PostgreSQL catalog query backing MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS. * * @return string SQL query. */ @@ -13599,6 +13600,7 @@ private function is_information_schema_table_scoped_show_query( array $tokens ): && ( WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[1]->id || WP_MySQL_Lexer::INDEXES_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::KEYS_SYMBOL === $tokens[1]->id ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 208251abe..fb6ed080b 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -7258,12 +7258,14 @@ public function test_use_statement_information_schema_table_metadata_handlers_fa $this->assertSame( 0, $index_driver->query( 'USE information_schema' ) ); - try { - $index_driver->query( 'SHOW INDEX FROM wptests_options' ); - $this->fail( 'Expected information_schema SHOW INDEX to throw.' ); - } catch ( InvalidArgumentException $e ) { - $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); - $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); + foreach ( array( 'SHOW INDEX FROM wptests_options', 'SHOW KEYS FROM wptests_options' ) as $query ) { + try { + $index_driver->query( $query ); + $this->fail( 'Expected information_schema SHOW INDEX to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries(), $query ); + } } $show_create_driver = $this->create_driver(); @@ -7669,6 +7671,29 @@ public function test_show_index_returns_mysql_shaped_catalog_rows(): void { $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); } + /** + * Tests SHOW KEYS returns the same MySQL-shaped rows as SHOW INDEX. + */ + public function test_show_keys_returns_same_catalog_rows_as_show_index(): void { + $index_driver = $this->create_show_index_driver(); + $keys_driver = $this->create_show_index_driver(); + + $indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + $keys = $keys_driver->query( 'SHOW KEYS FROM `wptests_options`;' ); + + $this->assertEquals( $indexes, $keys ); + $this->assertSame( 'SHOW KEYS FROM `wptests_options`;', $keys_driver->get_last_mysql_query() ); + $this->assertSame( 15, $keys_driver->get_last_column_count() ); + $this->assertSame( 'Table', $keys_driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Key_name', $keys_driver->get_last_column_meta()[2]['name'] ); + + $queries = $keys_driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + /** * Tests SHOW INDEXES WHERE Key_name filters on normalized MySQL index names. */ @@ -7689,6 +7714,49 @@ public function test_show_indexes_where_key_name_filters_catalog_rows(): void { $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); } + /** + * Tests SHOW KEYS WHERE Key_name uses the SHOW INDEX key-name filter. + */ + public function test_show_keys_where_key_name_filters_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW KEYS FROM wptests_options WHERE Key_name = 'autoload'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); + } + + /** + * Tests unsupported SHOW KEYS clauses fail before reaching the backend. + */ + public function test_show_keys_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'SHOW KEYS IN wptests_options', + 'SHOW KEYS FROM wptests_options WHERE Non_unique = 0', + 'SHOW KEYS FROM wptests_options LIMIT 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_show_index_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW KEYS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW INDEX statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests catalog-backed MySQL introspection queries are cached until metadata changes. */ From 5612ab0909ac917018649c539fc9a6267bbcb33e Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:39:01 +0000 Subject: [PATCH 121/142] Reject SHOW EXTENDED index forms --- .../postgresql/class-wp-postgresql-driver.php | 40 ++++++++++++++----- .../tests/WP_PostgreSQL_Driver_Tests.php | 33 ++++++++++++++- 2 files changed, 61 insertions(+), 12 deletions(-) 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 index b9d404061..b681faa0b 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3812,6 +3812,9 @@ private function get_show_columns_table_reference( array $tokens, int $position /** * Parse a supported MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement. * + * SHOW EXTENDED INDEX-family statements are recognized as part of this + * family so unsupported forms fail before raw backend execution. + * * @param string $query MySQL query. * @return array{table: string, key_name: string|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. */ @@ -3821,25 +3824,40 @@ private function get_show_index_query( string $query ): ?array { return null; } + $position = 1; + $has_extended_modifier = false; + if ( WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + $has_extended_modifier = true; + ++$position; + } + if ( - WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id - && WP_MySQL_Lexer::INDEXES_SYMBOL !== $tokens[1]->id - && WP_MySQL_Lexer::KEYS_SYMBOL !== $tokens[1]->id + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::INDEXES_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::KEYS_SYMBOL !== $tokens[ $position ]->id + ) ) { return null; } - if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id ) { + if ( $has_extended_modifier ) { throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } - $table_name = $this->get_mysql_identifier_token_value( $tokens[3] ?? null ); + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); if ( null === $table_name ) { throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } - $position = 4; - $key_name = null; + $position += 2; + $key_name = null; if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { $where_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); if ( @@ -13596,11 +13614,11 @@ private function is_information_schema_table_scoped_show_query( array $tokens ): return true; } - return isset( $tokens[1] ) + return isset( $tokens[ $position ] ) && ( - WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[1]->id - || WP_MySQL_Lexer::INDEXES_SYMBOL === $tokens[1]->id - || WP_MySQL_Lexer::KEYS_SYMBOL === $tokens[1]->id + WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::INDEXES_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::KEYS_SYMBOL === $tokens[ $position ]->id ); } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index fb6ed080b..b91b374fc 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -7258,7 +7258,15 @@ public function test_use_statement_information_schema_table_metadata_handlers_fa $this->assertSame( 0, $index_driver->query( 'USE information_schema' ) ); - foreach ( array( 'SHOW INDEX FROM wptests_options', 'SHOW KEYS FROM wptests_options' ) as $query ) { + foreach ( + array( + 'SHOW INDEX FROM wptests_options', + 'SHOW KEYS FROM wptests_options', + 'SHOW EXTENDED INDEX FROM wptests_options', + 'SHOW EXTENDED INDEXES FROM wptests_options', + 'SHOW EXTENDED KEYS FROM wptests_options', + ) as $query + ) { try { $index_driver->query( $query ); $this->fail( 'Expected information_schema SHOW INDEX to throw.' ); @@ -7757,6 +7765,29 @@ public function test_show_keys_unsupported_syntax_does_not_reach_backend(): void } } + /** + * Tests unsupported SHOW EXTENDED INDEX-family statements fail before reaching the backend. + */ + public function test_show_extended_index_family_does_not_reach_backend(): void { + $queries = array( + 'SHOW EXTENDED INDEX FROM wptests_options', + 'SHOW EXTENDED INDEXES FROM wptests_options', + 'SHOW EXTENDED KEYS FROM wptests_options', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_show_index_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW EXTENDED INDEX statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW INDEX statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests catalog-backed MySQL introspection queries are cached until metadata changes. */ From a6ab43e9a5b09d9ec8238439fe3c5c6df79b5800 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:45:52 +0000 Subject: [PATCH 122/142] Support SHOW FIELDS in PostgreSQL driver --- .../postgresql/class-wp-postgresql-driver.php | 20 ++- .../tests/WP_PostgreSQL_Driver_Tests.php | 145 +++++++++++++++++- 2 files changed, 155 insertions(+), 10 deletions(-) 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 index b681faa0b..59aa3099b 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3681,10 +3681,10 @@ private function is_mysql_quoted_text_token( WP_MySQL_Token $token ): bool { } /** - * Parse a supported MySQL SHOW COLUMNS/SHOW FULL COLUMNS statement. + * Parse a supported MySQL SHOW COLUMNS/FIELDS statement. * * @param string $query MySQL query. - * @return array{schema: string, table: string, full: bool, like: string|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS statement. + * @return array{schema: string, table: string, full: bool, like: string|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS/FIELDS statement. */ private function get_show_columns_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -3703,7 +3703,13 @@ private function get_show_columns_query( string $query ): ?array { ++$position; } - if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COLUMNS_SYMBOL !== $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::COLUMNS_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::FIELDS_SYMBOL !== $tokens[ $position ]->id + ) + ) { return null; } @@ -13602,7 +13608,13 @@ private function is_information_schema_table_scoped_show_query( array $tokens ): ++$position; } - if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[ $position ]->id ) { + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::FIELDS_SYMBOL === $tokens[ $position ]->id + ) + ) { return true; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index b91b374fc..755e4e479 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6243,6 +6243,63 @@ public function test_show_full_columns_returns_mysql_shaped_catalog_rows(): void $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); } + /** + * Tests SHOW FIELDS returns the same MySQL-shaped rows as SHOW COLUMNS. + */ + public function test_show_fields_returns_same_catalog_rows_as_show_columns(): void { + $columns_driver = $this->create_driver(); + $fields_driver = $this->create_driver(); + $this->install_information_schema_fixture( $columns_driver ); + $this->install_information_schema_fixture( $fields_driver ); + + $columns = $columns_driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + $fields = $fields_driver->query( 'SHOW FIELDS FROM `wptests_options`' ); + + $this->assertEquals( $columns, $fields ); + $this->assertSame( 'SHOW FIELDS FROM `wptests_options`', $fields_driver->get_last_mysql_query() ); + $this->assertSame( $columns_driver->get_last_column_count(), $fields_driver->get_last_column_count() ); + $this->assertSame( $columns_driver->get_last_column_meta(), $fields_driver->get_last_column_meta() ); + + $queries = $fields_driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FULL/EXTENDED FIELDS use the SHOW COLUMNS parser. + */ + public function test_show_prefixed_fields_use_show_columns_parser(): void { + $full_driver = $this->create_driver(); + $this->install_information_schema_fixture( $full_driver ); + + $full = $full_driver->query( 'SHOW FULL FIELDS FROM `wptests_options`' ); + + $this->assertCount( 4, $full ); + $this->assertSame( 9, $full_driver->get_last_column_count() ); + $this->assertSame( 'Collation', $full_driver->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $full[1]->Collation ); + $this->assertStringNotContainsString( + 'SHOW FULL FIELDS', + strtoupper( $full_driver->get_last_postgresql_queries()[0]['sql'] ) + ); + + $extended_driver = $this->create_driver(); + $this->install_information_schema_fixture( $extended_driver ); + + $extended = $extended_driver->query( 'SHOW EXTENDED FIELDS FROM `wptests_options`' ); + + $this->assertCount( 4, $extended ); + $this->assertSame( 6, $extended_driver->get_last_column_count() ); + $this->assertSame( 'Field', $extended_driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'option_id', $extended[0]->Field ); + $this->assertStringNotContainsString( + 'SHOW EXTENDED FIELDS', + strtoupper( $extended_driver->get_last_postgresql_queries()[0]['sql'] ) + ); + } + /** * Tests SHOW COLUMNS accepts MySQL table qualification forms. */ @@ -6272,6 +6329,35 @@ public function test_show_columns_accepts_table_qualification_forms(): void { } } + /** + * Tests SHOW FIELDS accepts MySQL table qualification forms. + */ + public function test_show_fields_accepts_table_qualification_forms(): void { + $cases = array( + 'SHOW FIELDS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS FROM public.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS IN wptests_options IN public' => array( 'public', 'wptests_options' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ), $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + /** * Tests SHOW COLUMNS LIKE filters catalog rows with bound parameters. */ @@ -6294,6 +6380,28 @@ public function test_show_columns_like_filters_catalog_rows(): void { $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); } + /** + * Tests SHOW FIELDS LIKE filters catalog rows with bound parameters. + */ + public function test_show_fields_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW FIELDS FROM wptests_options LIKE 'option_%'" ); + + $this->assertCount( 3, $result ); + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); + } + /** * Tests SHOW COLUMNS uses the same MySQL metadata rows as DESCRIBE. */ @@ -6561,6 +6669,22 @@ public function test_show_columns_where_clause_does_not_reach_backend(): void { } } + /** + * Tests unsupported SHOW FIELDS clauses do not fall through to the backend. + */ + public function test_show_fields_where_clause_does_not_reach_backend(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( "SHOW FIELDS FROM wptests_options WHERE Field = 'option_name'" ); + $this->fail( 'Expected unsupported SHOW FIELDS WHERE clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests SHOW TABLES returns MySQL-shaped catalog rows. */ @@ -7246,12 +7370,21 @@ public function test_use_statement_information_schema_table_metadata_handlers_fa $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); - try { - $driver->query( 'SHOW COLUMNS FROM wptests_options' ); - $this->fail( 'Expected information_schema SHOW COLUMNS to throw.' ); - } catch ( InvalidArgumentException $e ) { - $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); - $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + foreach ( + array( + 'SHOW COLUMNS FROM wptests_options', + 'SHOW FIELDS FROM wptests_options', + 'SHOW FULL FIELDS FROM wptests_options', + 'SHOW EXTENDED FIELDS FROM wptests_options', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema SHOW COLUMNS/FIELDS to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } } $index_driver = $this->create_show_index_driver(); From 8c61820f74000691c524f452eddbd9ffa51f490c Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 15:54:34 +0000 Subject: [PATCH 123/142] Support PostgreSQL SHOW VARIABLES forms --- .../postgresql/class-wp-postgresql-driver.php | 54 ++++-- .../tests/WP_PostgreSQL_Driver_Tests.php | 176 ++++++++++++++++++ 2 files changed, 212 insertions(+), 18 deletions(-) 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 index 59aa3099b..9bef8383c 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -3420,46 +3420,64 @@ private function is_mysql_unsigned_integer_token( WP_MySQL_Token $token ): bool * Parse a supported MySQL SHOW VARIABLES statement. * * @param string $query MySQL query. - * @return array{type: string, pattern: string}|null SHOW VARIABLES options, or null when unsupported. + * @return array{type: string, pattern: string|null}|null SHOW VARIABLES options, or null when this is not SHOW VARIABLES. */ private function get_show_variables_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; if ( - ! isset( $tokens[0], $tokens[1] ) - || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id - || WP_MySQL_Lexer::VARIABLES_SYMBOL !== $tokens[1]->id + WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VARIABLES_SYMBOL !== $tokens[ $position ]->id ) { return null; } + ++$position; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + 'pattern' => null, + ); + } + if ( - isset( $tokens[2], $tokens[3] ) - && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[2]->id - && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[3]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[3]->id ) - && $this->is_at_mysql_query_end( $tokens, 4 ) + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) ) { return array( 'type' => 'like', - 'pattern' => strtolower( $tokens[3]->get_value() ), + 'pattern' => strtolower( $tokens[ $position + 1 ]->get_value() ), ); } if ( - isset( $tokens[2], $tokens[3], $tokens[4], $tokens[5] ) - && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[2]->id - && WP_MySQL_Lexer::IDENTIFIER === $tokens[3]->id - && 'variable_name' === strtolower( $tokens[3]->get_value() ) - && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[4]->id - && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[5]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[5]->id ) - && $this->is_at_mysql_query_end( $tokens, 6 ) + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + && 'Variable_name' === $this->get_mysql_show_output_column_name( + $tokens[ $position + 1 ], + array( 'variable_name' => 'Variable_name' ) + ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 2 ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 4 ) ) { return array( 'type' => 'exact', - 'pattern' => strtolower( $tokens[5]->get_value() ), + 'pattern' => strtolower( $tokens[ $position + 3 ]->get_value() ), ); } - return null; + throw new InvalidArgumentException( 'Unsupported SHOW VARIABLES statement.' ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 755e4e479..6f90cb8a2 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -8233,6 +8233,182 @@ public function test_select_session_sql_mode_returns_emulated_driver_state(): vo $this->assertSame( '@@SESSION.sql_mode', $driver->get_last_column_meta()[0]['name'] ); } + /** + * Tests bare SHOW VARIABLES returns all emulated session variables. + */ + public function test_bare_show_variables_returns_all_known_session_variables_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $rows = $driver->query( 'SHOW VARIABLES' ); + + $this->assertSame( + array( + array( + 'Variable_name' => 'character_set_client', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_connection', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_results', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_database', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_server', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'collation_connection', + 'Value' => 'utf8_general_ci', + ), + array( + 'Variable_name' => 'collation_database', + 'Value' => 'utf8_general_ci', + ), + array( + 'Variable_name' => 'collation_server', + 'Value' => 'utf8_general_ci', + ), + ), + array_map( + static function ( $row ) { + return array( + 'Variable_name' => $row->Variable_name, + 'Value' => $row->Value, + ); + }, + $rows + ) + ); + $this->assertSame( 'SHOW VARIABLES', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 2, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Variable_name', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Variable_name', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 64, + 'precision' => 0, + 'native_type' => 'string', + ), + array( + 'name' => 'Value', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Value', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + } + + /** + * Tests SHOW GLOBAL/SESSION VARIABLES match bare SHOW VARIABLES. + */ + public function test_scoped_show_variables_matches_bare_show_variables(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $bare = $driver->query( 'SHOW VARIABLES' ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global = $driver->query( 'SHOW GLOBAL VARIABLES' ); + $this->assertEquals( $bare, $global ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session = $driver->query( 'SHOW SESSION VARIABLES' ); + $this->assertEquals( $bare, $session ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests scoped SHOW VARIABLES LIKE and WHERE filters are emulated. + */ + public function test_scoped_show_variables_like_and_where_filters_work(): void { + $driver = $this->create_driver(); + + $global_like = $driver->query( "SHOW GLOBAL VARIABLES LIKE 'character_set_c%'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $global_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_like = $driver->query( "SHOW SESSION VARIABLES LIKE 'collation_%'" ); + $this->assertSame( + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $session_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global_where = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $global_where ); + $this->assertSame( 'character_set_client', $global_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4', $global_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_where = $driver->query( "SHOW SESSION VARIABLES WHERE Variable_name = 'collation_connection'" ); + $this->assertCount( 1, $session_where ); + $this->assertSame( 'collation_connection', $session_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4_unicode_ci', $session_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW VARIABLES WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_variables_where_clause_does_not_reach_backend(): void { + $driver = $this->create_driver(); + + try { + $driver->query( "SHOW VARIABLES WHERE Value = 'utf8mb4'" ); + $this->fail( 'Expected unsupported SHOW VARIABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW VARIABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests SET NAMES updates MySQL-compatible SHOW VARIABLES output. */ From 01f5629de752f556efb4424f3bb278f89b9d7fe9 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 16:04:05 +0000 Subject: [PATCH 124/142] Add PostgreSQL LOCK TABLES compatibility --- .../postgresql/class-wp-postgresql-driver.php | 125 +++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 167 ++++++++++++++++++ 2 files changed, 292 insertions(+) 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 index 9bef8383c..c60d6fc69 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -488,6 +488,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo throw new InvalidArgumentException( 'Unsupported information_schema query.' ); } + $lock_tables_query = $this->get_mysql_lock_tables_query( $query ); + if ( null !== $lock_tables_query ) { + return $this->execute_mysql_lock_tables_query( $lock_tables_query ); + } + if ( $this->is_found_rows_query( $query ) ) { $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); $this->last_column_meta = array( @@ -4004,6 +4009,125 @@ private function get_mysql_table_administration_table_reference( array $tokens, ); } + /** + * Parse a supported MySQL LOCK/UNLOCK TABLES statement. + * + * @param string $query MySQL query. + * @return array{operation: string, tables: array}|null Lock query, or null when this is not LOCK/UNLOCK. + */ + private function get_mysql_lock_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::UNLOCK_SYMBOL === $tokens[0]->id ) { + if ( + ! isset( $tokens[1] ) + || ( + WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[1]->id + ) + || ! $this->is_at_mysql_query_end( $tokens, 2 ) + ) { + throw new InvalidArgumentException( 'Unsupported UNLOCK TABLES statement.' ); + } + + return array( + 'operation' => 'unlock', + 'tables' => array(), + ); + } + + if ( WP_MySQL_Lexer::LOCK_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( + ! isset( $tokens[1] ) + || ( + WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[1]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $tables = array(); + $position = 2; + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! isset( $tokens[ $position ] ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + if ( WP_MySQL_Lexer::READ_SYMBOL === $tokens[ $position ]->id ) { + $mode = 'read'; + } elseif ( WP_MySQL_Lexer::WRITE_SYMBOL === $tokens[ $position ]->id ) { + $mode = 'write'; + } else { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + ++$position; + $tables[] = array( + 'schema' => $table_reference['schema'], + 'table' => $table_reference['table'], + 'mode' => $mode, + ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + return array( + 'operation' => 'lock', + 'tables' => $tables, + ); + } + + /** + * Execute a supported MySQL LOCK/UNLOCK TABLES statement as a compatibility no-op. + * + * @param array $lock_tables_query Parsed lock query. + * @return int Number of affected rows. + */ + private function execute_mysql_lock_tables_query( array $lock_tables_query ): int { + if ( 'unlock' === $lock_tables_query['operation'] ) { + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + foreach ( $lock_tables_query['tables'] as $table_reference ) { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( + ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) + ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + if ( ! $this->mysql_table_administration_table_exists( $requested_schema, $table_name ) ) { + $table_label = $this->get_mysql_table_administration_result_table_name( $requested_schema, $table_name ); + throw new InvalidArgumentException( sprintf( "Table '%s' doesn't exist", $table_label ) ); + } + } + + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + /** * Execute a MySQL table administration statement. * @@ -13580,6 +13704,7 @@ private function should_reject_information_schema_backend_query( string $query ) 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, diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 6f90cb8a2..7fa1220e4 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -8217,6 +8217,173 @@ public function test_mysql_transaction_control_statements_use_fast_backend_path( $this->assertSame( 0, $driver->get_last_column_count() ); } + /** + * Tests UNLOCK TABLES forms are MySQL compatibility no-ops. + */ + public function test_mysql_unlock_tables_statements_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + + foreach ( array( 'UNLOCK TABLES', 'UNLOCK TABLE' ) as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES forms validate existing tables and then no-op. + */ + public function test_mysql_lock_tables_existing_tables_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_table_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_two (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_three (id INTEGER)' ); + + $cases = array( + 'LOCK TABLES lock_table_one READ', + 'LOCK TABLES lock_table_one WRITE', + 'LOCK TABLE lock_table_one READ', + 'LOCK TABLE lock_table_one WRITE', + 'LOCK TABLES lock_table_one READ, lock_table_two READ, lock_table_three WRITE', + ); + + foreach ( $cases as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES accepts main database-qualified table references. + */ + public function test_mysql_lock_tables_accepts_main_database_qualified_table_references(): void { + $driver = $this->create_driver( 'wp' ); + $driver->query( 'CREATE TABLE lock_qualified_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES wp.lock_qualified_table READ' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES accepts existing temporary tables. + */ + public function test_mysql_lock_tables_accepts_existing_temporary_tables(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TEMPORARY TABLE lock_temp_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_temp_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES missing targets fail before raw backend execution. + */ + public function test_mysql_lock_tables_missing_table_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_existing_table (id INTEGER)' ); + + try { + $driver->query( 'LOCK TABLES lock_existing_table READ, lock_missing_table WRITE' ); + $this->fail( 'Expected missing LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Table 'wptests.lock_missing_table' doesn't exist", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES information_schema targets fail closed. + */ + public function test_mysql_lock_tables_information_schema_targets_fail_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'LOCK TABLES information_schema.tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES under USE information_schema fails closed. + */ + public function test_mysql_lock_tables_after_use_information_schema_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE tables (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'LOCK TABLES tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported LOCK TABLES modes fail before raw backend execution. + */ + public function test_mysql_lock_tables_unsupported_modes_fail_before_backend_execution(): void { + $queries = array( + 'LOCK TABLES lock_mode_table LOW_PRIORITY WRITE', + 'LOCK TABLES lock_mode_table READ LOCAL', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_mode_table (id INTEGER)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LOCK TABLES mode to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests LOCK/UNLOCK TABLES no-ops do not commit user transactions. + */ + public function test_mysql_lock_tables_noop_does_not_commit_user_transaction(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_transaction_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $this->assertSame( 1, $driver->query( 'INSERT INTO lock_transaction_table (id) VALUES (1)' ) ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_transaction_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'UNLOCK TABLES' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK' ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS lock_row_count FROM lock_transaction_table' ); + $this->assertSame( 0, (int) $rows[0]->lock_row_count ); + } + /** * Tests the emulated MySQL session SQL mode can be selected. */ From e318f831152a99276199efe3039ec629c092006d Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 16:57:19 +0000 Subject: [PATCH 125/142] Support PostgreSQL SET session variables --- .../postgresql/class-wp-postgresql-driver.php | 1230 +++++++++++++---- .../tests/WP_PostgreSQL_Driver_Tests.php | 203 ++- 2 files changed, 1132 insertions(+), 301 deletions(-) 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 index c60d6fc69..488a828d3 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -58,6 +58,13 @@ class WP_PostgreSQL_Driver { */ public $client_info; + /** + * MySQL server version emulated by the driver. + * + * @var int + */ + private $mysql_version; + /** * PostgreSQL connection. * @@ -264,6 +271,20 @@ class WP_PostgreSQL_Driver { */ private $collation = self::DEFAULT_MYSQL_COLLATION; + /** + * MySQL-compatible session variable overrides. + * + * @var array + */ + private $mysql_session_variable_values = array(); + + /** + * MySQL-compatible user variables. + * + * @var array + */ + private $mysql_user_variables = array(); + /** * Narrow in-memory procedure registry for WordPress mysqli compatibility tests. * @@ -276,17 +297,18 @@ class WP_PostgreSQL_Driver { * * @param WP_PostgreSQL_Connection $connection PostgreSQL connection. * @param string $database MySQL-facing database name. - * @param int $mysql_version Reserved for parity with the SQLite driver. + * @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->client_info = $this->read_server_version(); + $this->connection = $connection; + $this->main_db_name = $database; + $this->db_name = $database; + $this->mysql_version = $mysql_version; + $this->client_info = $this->read_server_version(); $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } @@ -343,6 +365,7 @@ public function get_insert_id() { */ public function set_sql_mode( string $sql_mode ): void { $this->sql_mode = $sql_mode; + unset( $this->mysql_session_variable_values['sql_mode'] ); } /** @@ -364,6 +387,7 @@ 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; } @@ -371,6 +395,7 @@ public function set_charset( string $charset, ?string $collation = null ): void $this->collation = null === $collation || '' === $collation ? $this->get_default_mysql_collation_for_charset( $this->charset ) : $this->normalize_mysql_collation_name( $collation ); + $this->sync_mysql_charset_session_variables(); } /** @@ -403,19 +428,9 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $this->reset_query_state(); $this->last_mysql_query = $query; - if ( $this->is_fast_noop_mysql_runtime_setting( $query ) ) { - $this->last_result = 0; - return $this->last_result; - } - - if ( $this->is_noop_mysql_runtime_setting( $query ) ) { - $this->last_result = 0; - return $this->last_result; - } - - if ( $this->apply_mysql_set_names_query( $query ) ) { - $this->last_result = 0; - return $this->last_result; + $runtime_setting_result = $this->execute_mysql_runtime_setting_query( $query ); + if ( null !== $runtime_setting_result ) { + return $runtime_setting_result; } $use_database_name = $this->get_mysql_use_database_name( $query ); @@ -433,25 +448,13 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $procedure_result; } - $sql_mode_variable = $this->get_sql_mode_select_variable( $query ); - if ( null !== $sql_mode_variable ) { - $this->last_result = array( (object) array( $sql_mode_variable => $this->sql_mode ) ); - $this->last_column_meta = array( - array( - 'name' => $sql_mode_variable, - 'table' => '', - 'mysqli:orgtable' => '', - 'mysqli:orgname' => $sql_mode_variable, - 'mysqli:db' => $this->db_name, - 'mysqli:charsetnr' => 45, - 'mysqli:flags' => 0, - 'mysqli:type' => 253, - 'len' => 1024, - 'precision' => 0, - 'native_type' => 'string', - ), + $mysql_variable_select_query = $this->get_mysql_variable_select_query( $query ); + if ( null !== $mysql_variable_select_query ) { + return $this->execute_mysql_variable_select_query( + $mysql_variable_select_query, + $fetch_mode, + ...$fetch_mode_args ); - return $this->last_result; } $database_function_column = $this->get_mysql_database_function_select_column( $query ); @@ -1564,68 +1567,49 @@ private function execute_mysql_transaction_control_query( string $statement ): i } /** - * Check whether a MySQL runtime setting can be ignored without tokenization. - * - * This covers the high-frequency WordPress PHPUnit transaction setup path. - * Less common supported SET shapes still fall through to the lexer-backed - * runtime-setting handler. - * - * @param string $query MySQL query. - * @return bool Whether the query should be treated as a successful no-op. - */ - private function is_fast_noop_mysql_runtime_setting( string $query ): bool { - return 1 === preg_match( - '/\A\s*SET\s+(?:(?:GLOBAL|LOCAL|SESSION)\s+)?(?:autocommit|default_storage_engine|foreign_key_checks|sql_mode|storage_engine)\s*=\s*(?:\'[^\']*\'|"[^"]*"|[^\s,;]+)\s*;?\s*\z/i', - $query - ); - } - - /** - * Get the canonical PostgreSQL transaction statement for a simple MySQL query. + * Execute a supported MySQL runtime SET statement from emulated session state. * * @param string $query MySQL query. - * @return string|null Canonical PostgreSQL statement, or null when unsupported. + * @return int|null Query result for handled SET statements, or null when this is not SET. */ - 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 ) { + 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; } - $statement = trim( $normalized_statement ); - if ( '' === $statement ) { - return null; + if ( $this->apply_mysql_set_names_tokens( $tokens ) || $this->apply_mysql_set_charset_tokens( $tokens ) ) { + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; } - if ( 1 === preg_match( '/\A(?:START TRANSACTION|BEGIN(?: WORK)?)\z/i', $statement ) ) { - return 'BEGIN'; + $operations = $this->get_mysql_set_assignment_operations( $tokens ); + if ( null === $operations ) { + throw new InvalidArgumentException( 'Unsupported SET statement.' ); } - if ( 1 === preg_match( '/\ACOMMIT(?: WORK)?\z/i', $statement ) ) { - return 'COMMIT'; - } + foreach ( $operations as $operation ) { + if ( 'user' === $operation['target_type'] ) { + $this->mysql_user_variables[ $operation['name'] ] = $operation['value']; + continue; + } - if ( 1 === preg_match( '/\AROLLBACK(?: WORK)?\z/i', $statement ) ) { - return 'ROLLBACK'; + $this->set_mysql_session_variable_value( $operation['name'], $operation['value'] ); } - return null; + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; } /** * Apply a supported MySQL SET NAMES statement to the emulated session. * - * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. * @return bool Whether the query was handled. */ - private function apply_mysql_set_names_query( string $query ): bool { - $tokens = $this->get_mysql_tokens( $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 @@ -1656,6 +1640,327 @@ private function apply_mysql_set_names_query( string $query ): bool { return true; } + /** + * Apply supported MySQL SET CHARSET and SET CHARACTER SET statements. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the query was handled. + */ + private function apply_mysql_set_charset_tokens( array $tokens ): bool { + if ( + isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[0]->id + && $this->is_mysql_token_value( $tokens[1], 'charset' ) + && $this->is_mysql_charset_token( $tokens[2] ) + && $this->is_at_mysql_query_end( $tokens, 3 ) + ) { + $this->set_charset( $this->get_mysql_charset_token_value( $tokens[2] ) ); + return true; + } + + if ( + isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[0]->id + && $this->is_mysql_token_value( $tokens[1], 'character' ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[2]->id + && $this->is_mysql_charset_token( $tokens[3] ) + && $this->is_at_mysql_query_end( $tokens, 4 ) + ) { + $this->set_charset( $this->get_mysql_charset_token_value( $tokens[3] ) ); + return true; + } + + return false; + } + + /** + * Parse supported MySQL SET assignment operations. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return array|null Assignment operations, or null when unsupported. + */ + private function get_mysql_set_assignment_operations( array $tokens ): ?array { + $position = 1; + $this->get_mysql_set_statement_scope( $tokens, $position ); + $ops = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $target = $this->parse_mysql_set_assignment_target( $tokens, $position ); + if ( null === $target ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $operation = $this->parse_mysql_set_assignment_operation( $tokens, $position, $target ); + if ( null === $operation ) { + return null; + } + + $ops[] = $operation; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( array() === $ops || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return $ops; + } + + /** + * Get the statement-level SET scope, if present. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string|null SET scope. + */ + private function get_mysql_set_statement_scope( array $tokens, int &$position ): ?string { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::SESSION_SYMBOL, + ), + true + ) + ) { + return strtolower( $tokens[ $position++ ]->get_value() ); + } + + return null; + } + + /** + * Parse a SET assignment target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{type: string, name: string}|null Assignment target. + */ + private function parse_mysql_set_assignment_target( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return array( + 'type' => 'user', + 'name' => $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ), + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $name = $this->parse_mysql_system_variable_reference( $tokens, $position ); + if ( null === $name || ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + return array( + 'type' => 'system', + 'name' => $name, + ); + } + + $name = strtolower( $tokens[ $position ]->get_value() ); + if ( ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + ++$position; + return array( + 'type' => 'system', + 'name' => $name, + ); + } + + /** + * Parse a SET assignment operation. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @return array{target_type: string, name: string, value: string|null}|null Assignment operation. + */ + private function parse_mysql_set_assignment_operation( array $tokens, int &$position, array $target ): ?array { + $value = $this->parse_mysql_set_assignment_value( $tokens, $position, $target ); + if ( null === $value ) { + return null; + } + + if ( 'system' === $target['type'] ) { + $value = $this->normalize_mysql_system_variable_assignment_value( $target['name'], $value ); + if ( null === $value ) { + return null; + } + } + + return array( + 'target_type' => $target['type'], + 'name' => $target['name'], + 'value' => $value, + ); + } + + /** + * Parse a supported SET assignment value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @return string|null Assignment value, or null when unsupported. + */ + private function parse_mysql_set_assignment_value( array $tokens, int &$position, array $target ): ?string { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $user_variable_name = $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id ) { + return $this->parse_mysql_user_variable_increment_value( + $tokens, + $position, + $target, + $user_variable_name + ); + } + + return $this->get_mysql_user_variable_value( $user_variable_name ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $system_variable_name = $this->parse_mysql_system_variable_reference( $tokens, $position ); + return null === $system_variable_name ? null : $this->get_mysql_system_variable_value( $system_variable_name ); + } + + $value = $this->get_mysql_set_literal_token_value( $tokens[ $position ] ); + if ( null === $value ) { + return null; + } + + ++$position; + return $value; + } + + /** + * Parse @name = @name + integer assignment values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @param string $source_variable_name Source user variable name. + * @return string|null Incremented value, or null when unsupported. + */ + private function parse_mysql_user_variable_increment_value( + array $tokens, + int &$position, + array $target, + string $source_variable_name + ): ?string { + if ( + 'user' !== $target['type'] + || $target['name'] !== $source_variable_name + || ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::PLUS_OPERATOR !== $tokens[ $position ]->id + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $current_value = $this->get_mysql_user_variable_value( $source_variable_name ); + if ( null === $current_value || ! preg_match( '/\A[0-9]+\z/', $current_value ) ) { + return null; + } + + $increment = $tokens[ $position + 1 ]->get_value(); + $position += 2; + return (string) ( (int) $current_value + (int) $increment ); + } + + /** + * Get a simple literal token value allowed in supported SET assignments. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string|null Literal value, or null when unsupported. + */ + private function get_mysql_set_literal_token_value( WP_MySQL_Token $token ): ?string { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_TEXT_SUFFIX, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return null; + } + + return $token->get_value(); + } + + /** + * Get the canonical PostgreSQL transaction statement for a simple MySQL query. + * + * @param string $query MySQL query. + * @return string|null Canonical PostgreSQL statement, or null when unsupported. + */ + private function get_mysql_transaction_control_query( string $query ): ?string { + $statement = trim( $query ); + $statement = preg_replace( '/;\s*\z/', '', $statement ); + if ( null === $statement ) { + return null; + } + + $normalized_statement = preg_replace( '/\s+/', ' ', $statement ); + if ( null === $normalized_statement ) { + return null; + } + + $statement = trim( $normalized_statement ); + if ( '' === $statement ) { + return null; + } + + if ( 1 === preg_match( '/\A(?:START TRANSACTION|BEGIN(?: WORK)?)\z/i', $statement ) ) { + return 'BEGIN'; + } + + if ( 1 === preg_match( '/\ACOMMIT(?: WORK)?\z/i', $statement ) ) { + return 'COMMIT'; + } + + if ( 1 === preg_match( '/\AROLLBACK(?: WORK)?\z/i', $statement ) ) { + return 'ROLLBACK'; + } + + return null; + } + /** * Create the MySQL schema metadata tables used by dbDelta emulation. */ @@ -5071,6 +5376,23 @@ private function is_unsigned_integer_string_greater_than( string $left, string $ return strcmp( $left, $right ) > 0; } + /** + * Execute a simple MySQL variable SELECT query from emulated variable state. + * + * @param array $mysql_variable_select_query Parsed variable SELECT query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Variable SELECT result rows. + */ + private function execute_mysql_variable_select_query( array $mysql_variable_select_query, $fetch_mode, ...$fetch_mode_args ) { + return $this->set_mysql_static_show_result( + $mysql_variable_select_query['columns'], + array( $mysql_variable_select_query['row'] ), + $fetch_mode, + ...$fetch_mode_args + ); + } + /** * Execute a MySQL SHOW VARIABLES statement from emulated session state. * @@ -5286,141 +5608,509 @@ private function execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ) { ); $this->last_column_meta[0]['len'] = 4096; - return $result; + return $result; + } + + /** + * Filter static SHOW rows with a parsed MySQL LIKE or WHERE filter. + * + * @param array[] $rows Rows keyed by output column names. + * @param array $show_filter Parsed SHOW filter. + * @return array[] Filtered rows. + */ + private function filter_mysql_static_show_rows( array $rows, array $show_filter ): array { + if ( 'all' === $show_filter['type'] ) { + return $rows; + } + + $column = $show_filter['column']; + $pattern = $show_filter['pattern']; + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_filter, $column, $pattern ): bool { + if ( null === $column || null === $pattern || ! array_key_exists( $column, $row ) ) { + return false; + } + + if ( 'like' === $show_filter['type'] ) { + return $this->matches_mysql_like_pattern( (string) $row[ $column ], $pattern ); + } + + return 0 === strcasecmp( (string) $row[ $column ], $pattern ); + } + ) + ); + } + + /** + * Store static SHOW result rows using common MySQL-shaped metadata. + * + * @param string[] $columns Result column names. + * @param array[] $rows Rows keyed by column names. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Result rows formatted for the requested fetch mode. + */ + private function set_mysql_static_show_result( array $columns, array $rows, $fetch_mode, ...$fetch_mode_args ) { + $this->last_column_meta = array(); + foreach ( $columns as $column ) { + $this->last_column_meta[] = array( + 'name' => $column, + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => $column, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ); + } + $this->last_column_count = count( $this->last_column_meta ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + + /** + * Match a string against a MySQL LIKE pattern. + * + * @param string $value Value to check. + * @param string $pattern MySQL LIKE pattern. + * @return bool Whether the pattern matches. + */ + private function matches_mysql_like_pattern( string $value, string $pattern ): bool { + $regex = '/^'; + $length = strlen( $pattern ); + + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char && $i + 1 < $length ) { + ++$i; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + $regex .= '$/i'; + return 1 === preg_match( $regex, $value ); + } + + /** + * Get MySQL-compatible session variables exposed by SHOW VARIABLES. + * + * @return array Session variables keyed by lowercase name. + */ + private function get_mysql_session_variables(): array { + return array_replace( + array( + 'character_set_client' => $this->charset, + 'character_set_connection' => $this->charset, + 'character_set_results' => $this->charset, + 'character_set_database' => $this->charset, + 'character_set_server' => $this->charset, + 'collation_connection' => $this->collation, + 'collation_database' => $this->collation, + 'collation_server' => $this->collation, + 'sql_mode' => $this->sql_mode, + ), + $this->mysql_session_variable_values + ); + } + + /** + * Synchronize SET NAMES/CHARSET state with individual session variables. + */ + private function sync_mysql_charset_session_variables(): void { + foreach ( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ) as $variable + ) { + $this->mysql_session_variable_values[ $variable ] = $this->charset; + } + + foreach ( + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ) as $variable + ) { + $this->mysql_session_variable_values[ $variable ] = $this->collation; + } + } + + /** + * Set an emulated MySQL session variable. + * + * @param string $name Lowercase variable name. + * @param string $value Variable value. + */ + private function set_mysql_session_variable_value( string $name, string $value ): void { + if ( 'sql_mode' === $name ) { + $this->set_sql_mode( $value ); + return; + } + + $this->mysql_session_variable_values[ $name ] = $value; + } + + /** + * Get an emulated MySQL system variable value. + * + * @param string $name Variable name. + * @return string|null Variable value, or null when unsupported. + */ + private function get_mysql_system_variable_value( string $name ): ?string { + $name = strtolower( $name ); + $variables = $this->get_mysql_session_variables(); + if ( array_key_exists( $name, $variables ) ) { + return $variables[ $name ]; + } + + if ( 'sql_mode' === $name ) { + return $this->sql_mode; + } + + $read_only_variables = $this->get_read_only_mysql_system_variable_values(); + if ( array_key_exists( $name, $read_only_variables ) ) { + return $read_only_variables[ $name ]; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ) ? $defaults[ $name ] : null; + } + + /** + * Get a stored MySQL user variable value. + * + * @param string $name Normalized user variable name. + * @return string|null User variable value, or null when unset. + */ + private function get_mysql_user_variable_value( string $name ): ?string { + return array_key_exists( $name, $this->mysql_user_variables ) ? $this->mysql_user_variables[ $name ] : null; + } + + /** + * Parse a MySQL @@system_variable reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param string|null $display Optional display name, populated when requested. + * @return string|null Lowercase system variable name, or null when unsupported. + */ + private function parse_mysql_system_variable_reference( array $tokens, int &$position, ?string &$display = null ): ?string { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display_parts = array( '@@' ); + ++$position; + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::SESSION_SYMBOL, + ), + true + ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $display_parts[] = $tokens[ $position ]->get_value(); + $display_parts[] = '.'; + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) || ! $this->is_mysql_system_variable_name_token( $tokens[ $position ] ) ) { + return null; + } + + $display_parts[] = $tokens[ $position ]->get_value(); + $name = strtolower( $tokens[ $position++ ]->get_value() ); + $display = implode( '', $display_parts ); + return $name; + } + + /** + * Check whether a token can be a system variable name. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token can name a supported variable. + */ + private function is_mysql_system_variable_name_token( WP_MySQL_Token $token ): bool { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_TEXT_SUFFIX, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return false; + } + + return '' !== $token->get_value(); } /** - * Filter static SHOW rows with a parsed MySQL LIKE or WHERE filter. + * Normalize a MySQL user variable name for storage. * - * @param array[] $rows Rows keyed by output column names. - * @param array $show_filter Parsed SHOW filter. - * @return array[] Filtered rows. + * @param string $name User variable token value. + * @return string Normalized user variable name. */ - private function filter_mysql_static_show_rows( array $rows, array $show_filter ): array { - if ( 'all' === $show_filter['type'] ) { - return $rows; - } - - $column = $show_filter['column']; - $pattern = $show_filter['pattern']; - - return array_values( - array_filter( - $rows, - function ( array $row ) use ( $show_filter, $column, $pattern ): bool { - if ( null === $column || null === $pattern || ! array_key_exists( $column, $row ) ) { - return false; - } - - if ( 'like' === $show_filter['type'] ) { - return $this->matches_mysql_like_pattern( (string) $row[ $column ], $pattern ); - } - - return 0 === strcasecmp( (string) $row[ $column ], $pattern ); - } - ) - ); + private function normalize_mysql_user_variable_name( string $name ): string { + return strtolower( ltrim( $name, '@' ) ); } /** - * Store static SHOW result rows using common MySQL-shaped metadata. + * Normalize a SET value for a supported system variable. * - * @param string[] $columns Result column names. - * @param array[] $rows Rows keyed by column names. - * @param int $fetch_mode PDO fetch mode. - * @param array ...$fetch_mode_args Additional fetch mode arguments. - * @return mixed Result rows formatted for the requested fetch mode. + * @param string $name Lowercase variable name. + * @param string $value Raw assignment value. + * @return string|null Normalized value, or null when unsupported. */ - private function set_mysql_static_show_result( array $columns, array $rows, $fetch_mode, ...$fetch_mode_args ) { - $this->last_column_meta = array(); - foreach ( $columns as $column ) { - $this->last_column_meta[] = array( - 'name' => $column, - 'table' => '', - 'mysqli:orgtable' => '', - 'mysqli:orgname' => $column, - 'mysqli:db' => $this->db_name, - 'mysqli:charsetnr' => 45, - 'mysqli:flags' => 0, - 'mysqli:type' => 253, - 'len' => 1024, - 'precision' => 0, - 'native_type' => 'string', - ); + private function normalize_mysql_system_variable_assignment_value( string $name, string $value ): ?string { + if ( $this->is_mysql_boolean_system_variable( $name ) ) { + return $this->normalize_mysql_boolean_system_variable_value( $value ); } - $this->last_column_count = count( $this->last_column_meta ); - if ( PDO::FETCH_ASSOC === $fetch_mode ) { - $this->last_result = $rows; - return $this->last_result; + if ( $this->is_mysql_charset_session_variable( $name ) || $this->is_mysql_collation_session_variable( $name ) ) { + return strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); } - if ( PDO::FETCH_NUM === $fetch_mode ) { - $this->last_result = array_map( 'array_values', $rows ); - return $this->last_result; + return $value; + } + + /** + * Normalize a MySQL boolean system variable value. + * + * @param string $value Raw assignment value. + * @return string|null Normalized 1/0 value, or null when unsupported. + */ + private function normalize_mysql_boolean_system_variable_value( string $value ): ?string { + $value = strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); + if ( in_array( $value, array( '1', 'on', 'true' ), true ) ) { + return '1'; } - $this->last_result = array_map( - static function ( array $row ) { - return (object) $row; - }, - $rows - ); + if ( in_array( $value, array( '0', 'off', 'false' ), true ) ) { + return '0'; + } - return $this->last_result; + return null; } /** - * Match a string against a MySQL LIKE pattern. + * Check whether a MySQL system variable is supported by the emulation layer. * - * @param string $value Value to check. - * @param string $pattern MySQL LIKE pattern. - * @return bool Whether the pattern matches. + * @param string $name Lowercase variable name. + * @return bool Whether the variable is supported. */ - private function matches_mysql_like_pattern( string $value, string $pattern ): bool { - $regex = '/^'; - $length = strlen( $pattern ); - - for ( $i = 0; $i < $length; $i++ ) { - $char = $pattern[ $i ]; - if ( '\\' === $char && $i + 1 < $length ) { - ++$i; - $regex .= preg_quote( $pattern[ $i ], '/' ); - continue; - } + 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; + } - if ( '%' === $char ) { - $regex .= '.*'; - continue; - } + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ); + } - if ( '_' === $char ) { - $regex .= '.'; - continue; - } + /** + * Check whether a variable stores a charset name. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a charset variable. + */ + private function is_mysql_charset_session_variable( string $name ): bool { + return in_array( + $name, + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + true + ); + } - $regex .= preg_quote( $char, '/' ); - } + /** + * Check whether a variable stores a collation name. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a collation variable. + */ + private function is_mysql_collation_session_variable( string $name ): bool { + return in_array( + $name, + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ), + true + ); + } - $regex .= '$/i'; - return 1 === preg_match( $regex, $value ); + /** + * Check whether a variable accepts MySQL boolean values. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a boolean variable. + */ + private function is_mysql_boolean_system_variable( string $name ): bool { + return in_array( + $name, + array( + 'autocommit', + 'big_tables', + 'end_markers_in_json', + 'explicit_defaults_for_timestamp', + 'foreign_key_checks', + 'keep_files_on_create', + 'old_alter_table', + 'print_identified_with_as_hex', + 'require_row_format', + 'select_into_disk_sync', + 'session_track_schema', + 'session_track_state_change', + 'show_create_table_skip_secondary_engine', + 'show_create_table_verbosity', + 'sql_auto_is_null', + 'sql_big_selects', + 'sql_buffer_result', + 'sql_notes', + 'sql_safe_updates', + 'sql_warnings', + 'transaction_read_only', + 'unique_checks', + ), + true + ); } /** - * Get MySQL-compatible session variables exposed by SHOW VARIABLES. + * Get defaults for supported MySQL system variables. * - * @return array Session variables keyed by lowercase name. + * @return array Default values keyed by lowercase name. */ - private function get_mysql_session_variables(): array { + private function get_default_mysql_system_variable_values(): array { return array( - 'character_set_client' => $this->charset, - 'character_set_connection' => $this->charset, - 'character_set_results' => $this->charset, - 'character_set_database' => $this->charset, - 'character_set_server' => $this->charset, - 'collation_connection' => $this->collation, - 'collation_database' => $this->collation, - 'collation_server' => $this->collation, + 'autocommit' => '1', + 'big_tables' => '0', + 'default_collation_for_utf8mb4' => 'utf8mb4_0900_ai_ci', + 'default_storage_engine' => 'InnoDB', + 'end_markers_in_json' => '0', + 'explicit_defaults_for_timestamp' => '1', + 'foreign_key_checks' => '1', + 'keep_files_on_create' => '0', + 'old_alter_table' => '0', + 'print_identified_with_as_hex' => '0', + 'require_row_format' => '0', + 'resultset_metadata' => 'FULL', + 'select_into_disk_sync' => '0', + 'session_track_gtids' => 'OFF', + 'session_track_schema' => '1', + 'session_track_state_change' => '0', + 'session_track_transaction_info' => 'OFF', + 'show_create_table_skip_secondary_engine' => '0', + 'show_create_table_verbosity' => '0', + 'sql_auto_is_null' => '0', + 'sql_big_selects' => '1', + 'sql_buffer_result' => '0', + 'sql_notes' => '1', + 'sql_safe_updates' => '0', + 'sql_warnings' => '0', + 'storage_engine' => 'InnoDB', + 'time_zone' => 'SYSTEM', + 'transaction_isolation' => 'REPEATABLE-READ', + 'transaction_read_only' => '0', + 'unique_checks' => '1', + 'use_secondary_engine' => 'ON', + ); + } + + /** + * Get read-only MySQL system variable values. + * + * @return array Read-only values keyed by lowercase name. + */ + private function get_read_only_mysql_system_variable_values(): array { + return array( + 'version' => $this->get_mysql_version_string(), + 'version_comment' => 'MySQL Community Server - GPL', + ); + } + + /** + * Get the emulated MySQL server version string. + * + * @return string MySQL-compatible version string. + */ + private function get_mysql_version_string(): string { + $version = (string) $this->mysql_version; + return sprintf( + '%d.%d.%d', + $version[0], + substr( $version, 1, 2 ), + substr( $version, 3, 2 ) ); } @@ -19877,44 +20567,96 @@ private function is_mysql_create_table_charset_set_marker( array $tokens, int $p } /** - * Get the selected MySQL sql_mode variable name from a supported query. + * Get a simple MySQL variable SELECT query. * * @param string $query MySQL query. - * @return string|null Selected variable name, or null when unsupported. + * @return array{columns: string[], row: array}|null Parsed variable query, or null when not applicable. */ - private function get_sql_mode_select_variable( string $query ): ?string { + private function get_mysql_variable_select_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); if ( - ! isset( $tokens[0], $tokens[1], $tokens[2] ) + ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id - || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[1]->id + || ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX !== $tokens[1]->id + && WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[1]->id + ) ) { return null; } - if ( - WP_MySQL_Lexer::IDENTIFIER === $tokens[2]->id - && 'sql_mode' === strtolower( $tokens[2]->get_value() ) - && $this->is_at_mysql_query_end( $tokens, 3 ) - ) { - return '@@' . $tokens[2]->get_value(); + $position = 1; + $columns = array(); + $row = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $variable = $this->parse_mysql_select_variable_reference( $tokens, $position ); + if ( null === $variable ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + $columns[] = $variable['display']; + $row[ $variable['display'] ] = $variable['value']; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; } - if ( - ! isset( $tokens[3], $tokens[4] ) - || ( - WP_MySQL_Lexer::SESSION_SYMBOL !== $tokens[2]->id - && WP_MySQL_Lexer::GLOBAL_SYMBOL !== $tokens[2]->id - ) - || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[3]->id - || WP_MySQL_Lexer::IDENTIFIER !== $tokens[4]->id - || 'sql_mode' !== strtolower( $tokens[4]->get_value() ) - || ! $this->is_at_mysql_query_end( $tokens, 5 ) - ) { + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + return array( + 'columns' => $columns, + 'row' => $row, + ); + } + + /** + * Parse a variable reference in a simple SELECT list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{display: string, value: string|null}|null Variable result descriptor. + */ + private function parse_mysql_select_variable_reference( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $display = $tokens[ $position ]->get_value(); + ++$position; + + return array( + 'display' => $display, + 'value' => $this->get_mysql_user_variable_value( $this->normalize_mysql_user_variable_name( $display ) ), + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display ); + if ( null === $name || null === $display ) { return null; } - return '@@' . $tokens[2]->get_value() . '.' . $tokens[4]->get_value(); + $value = $this->get_mysql_system_variable_value( $name ); + if ( null === $value ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + + return array( + 'display' => $display, + 'value' => $value, + ); } /** @@ -19961,86 +20703,6 @@ private function is_found_rows_query( string $query ): bool { return $this->is_at_mysql_query_end( $tokens, 4 ); } - /** - * Check whether a MySQL runtime setting is intentionally ignored. - * - * WordPress PHPUnit bootstrap emits MySQL-only SET statements before schema - * installation. PostgreSQL has no equivalent state for these settings, so they - * should not be sent to PDO. Keep this intentionally narrow so unsupported SET - * statements still fail visibly. - * - * @param string $query MySQL query. - * @return bool Whether the query should be treated as a successful no-op. - */ - private function is_noop_mysql_runtime_setting( string $query ): bool { - $lexer = new WP_MySQL_Lexer( $query ); - $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); - - if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[0]->id ) { - return false; - } - - $position = 1; - if ( - isset( $tokens[ $position ] ) - && in_array( - $tokens[ $position ]->id, - array( - WP_MySQL_Lexer::GLOBAL_SYMBOL, - WP_MySQL_Lexer::LOCAL_SYMBOL, - WP_MySQL_Lexer::SESSION_SYMBOL, - ), - true - ) - ) { - ++$position; - } - - if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::IDENTIFIER !== $tokens[ $position ]->id ) { - return false; - } - - $variable = strtolower( $tokens[ $position ]->get_value() ); - if ( - ! in_array( - $variable, - array( - 'autocommit', - 'default_storage_engine', - 'foreign_key_checks', - 'sql_mode', - 'storage_engine', - ), - true - ) - ) { - return false; - } - - ++$position; - if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { - return false; - } - - ++$position; - $has_value = false; - while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { - if ( WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $position ]->id ) { - ++$position; - break; - } - - if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { - return false; - } - - $has_value = true; - ++$position; - } - - return $has_value && isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF === $tokens[ $position ]->id; - } - /** * Read the backend server version without requiring a PostgreSQL-only 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 index 7fa1220e4..0d35ae028 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -8149,7 +8149,7 @@ static function ( string $sql ) use ( &$describe_catalog_queries ): void { } /** - * Tests MySQL-only runtime SET statements are ignored before reaching PDO. + * 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(); @@ -8400,6 +8400,45 @@ public function test_select_session_sql_mode_returns_emulated_driver_state(): vo $this->assertSame( '@@SESSION.sql_mode', $driver->get_last_column_meta()[0]['name'] ); } + /** + * Tests built-in MySQL version variables are selected from emulated state. + */ + public function test_select_builtin_version_variables_returns_mysql_compatible_values_without_backend_queries(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT @@version, @@version_comment' ); + + $this->assertSame( '8.0.38', $rows[0]->{'@@version'} ); + $this->assertSame( 'MySQL Community Server - GPL', $rows[0]->{'@@version_comment'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( '@@version', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( '@@version_comment', $driver->get_last_column_meta()[1]['name'] ); + } + + /** + * Tests public SQL mode changes override earlier SQL SET state consistently. + */ + public function test_public_sql_mode_setter_overrides_sql_set_state_for_select_and_show_variables(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'" ) ); + $this->assertSame( 'NO_AUTO_VALUE_ON_ZERO', $driver->get_sql_mode() ); + + $driver->set_sql_mode( 'STRICT_ALL_TABLES' ); + + $this->assertSame( 'STRICT_ALL_TABLES', $driver->get_sql_mode() ); + + $rows = $driver->query( 'SELECT @@sql_mode' ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->{'@@sql_mode'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'sql_mode', $rows[0]->Variable_name ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + /** * Tests bare SHOW VARIABLES returns all emulated session variables. */ @@ -8444,6 +8483,10 @@ public function test_bare_show_variables_returns_all_known_session_variables_wit 'Variable_name' => 'collation_server', 'Value' => 'utf8_general_ci', ), + array( + 'Variable_name' => 'sql_mode', + 'Value' => 'NO_ENGINE_SUBSTITUTION', + ), ), array_map( static function ( $row ) { @@ -8614,6 +8657,128 @@ public function test_set_names_default_resets_show_variables_session_state(): vo $this->assertSame( array(), $driver->get_last_postgresql_queries() ); } + /** + * Tests SET CHARSET aliases update MySQL-compatible SHOW VARIABLES output. + */ + public function test_set_charset_aliases_update_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET CHARSET utf8' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8_general_ci', $collation[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET CHARACTER SET utf8mb4' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8mb4', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8mb4_unicode_ci', $collation[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests supported MySQL session variables can be selected with MySQL aliases. + */ + public function test_session_system_variables_can_be_selected_with_mysql_aliases(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET character_set_client = 'latin1'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@character_set_client = 'utf8mb3'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb3', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@session.character_set_client = 'utf8mb4'" ) ); + $rows = $driver->query( 'SELECT @@session.character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@session.character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET default_storage_engine = InnoDB' ) ); + $rows = $driver->query( 'SELECT @@default_storage_engine' ); + $this->assertSame( 'InnoDB', $rows[0]->{'@@default_storage_engine'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests comma-separated boolean SET assignments are applied atomically. + */ + public function test_comma_separated_boolean_set_assignments_are_atomic(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET autocommit = ON, big_tables = OFF' ) ); + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + + try { + $driver->query( 'SET autocommit = OFF, unsupported_setting = 1' ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + } + + /** + * Tests user variables can be set, incremented, selected, and used for restore. + */ + public function test_user_variables_can_be_set_incremented_selected_and_used_for_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @my_var = 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '1', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @my_var = @my_var + 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '2', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @saved_cs_client = @@character_set_client' ) ); + $this->assertSame( 0, $driver->query( 'SET character_set_client = latin1' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET character_set_client = @saved_cs_client' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests conditional-comment SET wrappers work for supported backup and restore forms. + */ + public function test_conditional_comment_set_wrappers_handle_supported_backup_and_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( '/*!50503 SET NAMES utf8 */;' ) ); + $this->assertSame( 0, $driver->query( '/*!40101 SET @saved_cs_client = @@character_set_client */; ' ) ); + $this->assertSame( 0, $driver->query( '/*!50503 SET character_set_client = latin1 */;' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( '/*!40101 SET character_set_client = @saved_cs_client */;' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + /** * Tests SHOW VARIABLES LIKE honors MySQL wildcard patterns. */ @@ -8641,25 +8806,29 @@ static function ( $row ) { } /** - * Tests unsupported SET statements are still sent to PDO. + * Tests unsupported SET statements fail before reaching PDO. */ - public function test_unsupported_set_statement_still_reaches_backend(): void { + public function test_unsupported_set_statements_do_not_reach_backend(): void { $driver = $this->create_driver(); - $this->expectException( PDOException::class ); - - $driver->query( 'SET unsupported_setting = 1' ); - } - - /** - * Tests multi-assignment SET statements are not silently ignored. - */ - public function test_multi_assignment_set_statement_still_reaches_backend(): void { - $driver = $this->create_driver(); - - $this->expectException( PDOException::class ); - - $driver->query( 'SET foreign_key_checks = 0, unsupported_setting = 1' ); + 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() ); + } + } } /** From e36aecbb89eb1f40ad0ac2a31fbd596e292dc78e Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 17:41:27 +0000 Subject: [PATCH 126/142] Support PostgreSQL TRUNCATE and savepoints Add MySQL-compatible savepoint handling, including fail-closed handling for unsupported savepoint syntax before raw backend execution. Add scoped PostgreSQL TRUNCATE support and main database-qualified simple table targets. Keep unsupported extra-qualified CREATE TABLE targets fail-closed before DDL translation. --- .../class-wp-postgresql-connection.php | 2 +- ...-wp-postgresql-create-table-translator.php | 32 +- .../postgresql/class-wp-postgresql-driver.php | 405 ++++++++++++++++-- ...nnection_Pgsql_Quote_SQLite_Connection.php | 2 +- .../tests/WP_PostgreSQL_Driver_Tests.php | 165 ++++++- 5 files changed, 549 insertions(+), 57 deletions(-) diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php index d5092887c..93e871994 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -232,7 +232,7 @@ public function quote( $value, int $type = PDO::PARAM_STR ): string { * * @return string PDO driver name. */ - protected function get_driver_name(): string { + public function get_driver_name(): string { return (string) $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); } 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 index 8c6bc24cf..18de14753 100644 --- 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 @@ -374,7 +374,37 @@ private function get_key_parts( WP_Parser_Node $table_constraint ): array { * @return string Table name. */ private function get_table_name( WP_Parser_Node $create_table ): string { - return $this->get_identifier_value( $create_table->get_first_child_node( 'tableName' ) ); + 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.' ); } /** 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 index 488a828d3..603690bdb 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -443,6 +443,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_mysql_transaction_control_query( $transaction_control_query ); } + $savepoint_query = $this->get_mysql_savepoint_query( $query ); + if ( null !== $savepoint_query ) { + return $this->execute_mysql_savepoint_query( $savepoint_query ); + } + $procedure_result = $this->handle_mysql_procedure_query( $query, $fetch_mode, ...$fetch_mode_args ); if ( null !== $procedure_result ) { return $procedure_result; @@ -496,6 +501,11 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_mysql_lock_tables_query( $lock_tables_query ); } + $truncate_table_query = $this->get_mysql_truncate_table_query( $query ); + if ( null !== $truncate_table_query ) { + return $this->execute_mysql_truncate_table_query( $truncate_table_query ); + } + if ( $this->is_found_rows_query( $query ) ) { $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); $this->last_column_meta = array( @@ -517,6 +527,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } if ( $this->is_create_table_query( $query ) ) { + $this->validate_mysql_create_table_target_database( $query ); + $translator = new WP_PostgreSQL_Create_Table_Translator(); $result = $this->execute_postgresql_statements( $translator->translate_schema( $query ) ); if ( $this->is_temporary_create_table_query( $query ) ) { @@ -1566,6 +1578,24 @@ private function execute_mysql_transaction_control_query( string $statement ): i return $this->last_result; } + /** + * Execute a public MySQL savepoint statement. + * + * @param string $statement Canonical PostgreSQL savepoint statement. + * @return int Number of affected rows. + */ + private function execute_mysql_savepoint_query( string $statement ): int { + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = 0; + $this->clear_last_column_meta(); + + return $this->last_result; + } + /** * Execute a supported MySQL runtime SET statement from emulated session state. * @@ -1961,6 +1991,134 @@ private function get_mysql_transaction_control_query( string $query ): ?string { return null; } + /** + * Get the canonical PostgreSQL statement for a public MySQL savepoint query. + * + * @param string $query MySQL query. + * @return string|null Canonical PostgreSQL savepoint statement, or null when this is not SAVEPOINT SQL. + */ + private function get_mysql_savepoint_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[0]->id ) { + $position = 1; + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $tokens[0]->id ) { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WORK_SYMBOL === $tokens[ $position ]->id ) { + if ( isset( $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::TO_SYMBOL === $tokens[ $position + 1 ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return null; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'ROLLBACK TO SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $tokens[0]->id ) { + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SAVEPOINT_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + ++$position; + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'RELEASE SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + return null; + } + + /** + * Execute a supported MySQL TRUNCATE TABLE statement. + * + * @param array{schema: string|null, table: string} $truncate_table_query Parsed truncate query. + * @return int Number of affected rows. + */ + private function execute_mysql_truncate_table_query( array $truncate_table_query ): int { + $requested_schema = $truncate_table_query['schema']; + $table_name = $truncate_table_query['table']; + + if ( + ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 !== strcasecmp( $requested_schema, $this->main_db_name ) ) + ) { + throw new InvalidArgumentException( 'Unsupported TRUNCATE TABLE statement.' ); + } + + $statement = 'TRUNCATE TABLE ' . $this->connection->quote_identifier( $table_name ) . ' RESTART IDENTITY'; + if ( 'sqlite' === (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + $statement = 'DELETE FROM ' . $this->connection->quote_identifier( $table_name ); + } + + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + if ( 'sqlite' === (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + $this->reset_sqlite_autoincrement_sequence( $table_name ); + } + + $this->mysql_introspection_result_cache = array(); + $this->last_result = 0; + $this->clear_last_column_meta(); + + return $this->last_result; + } + + /** + * Reset a SQLite AUTOINCREMENT sequence after TRUNCATE emulation. + * + * @param string $table_name Table name. + */ + private function reset_sqlite_autoincrement_sequence( string $table_name ): void { + try { + $this->connection->query( + 'DELETE FROM sqlite_sequence WHERE name = ?', + array( $table_name ) + ); + } catch ( PDOException $e ) { + if ( false === strpos( $e->getMessage(), 'no such table' ) ) { + throw $e; + } + } + } + /** * Create the MySQL schema metadata tables used by dbDelta emulation. */ @@ -4314,6 +4472,31 @@ private function get_mysql_table_administration_table_reference( array $tokens, ); } + /** + * Parse a supported MySQL TRUNCATE TABLE statement. + * + * @param string $query MySQL query. + * @return array{schema: string|null, table: string}|null Truncate query, or null when this is not TRUNCATE. + */ + private function get_mysql_truncate_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::TRUNCATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported TRUNCATE TABLE statement.' ); + } + + return $table_reference; + } + /** * Parse a supported MySQL LOCK/UNLOCK TABLES statement. * @@ -4506,7 +4689,7 @@ private function get_mysql_table_administration_result_table_name( ?string $requ */ private function mysql_table_administration_table_exists( ?string $requested_schema, string $table_name ): bool { $schema_name = $this->get_mysql_table_administration_backend_schema( $requested_schema, $table_name ); - $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + $driver_name = $this->connection->get_driver_name(); if ( 'sqlite' === $driver_name ) { return $this->sqlite_table_administration_table_exists( $schema_name, $table_name ); @@ -6401,31 +6584,50 @@ private function resolve_mysql_table_schema_for_introspection( string $schema_na * @return string|null Temporary schema name, or null when no active temporary table exists. */ private function get_active_temporary_table_schema( string $table_name ): ?string { - $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + $driver_name = $this->connection->get_driver_name(); + $pdo_driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); if ( 'sqlite' === $driver_name ) { + return $this->get_active_sqlite_temporary_table_schema( $table_name ); + } + + try { $stmt = $this->connection->query( - "SELECT name FROM sqlite_temp_master WHERE type = 'table' AND LOWER(name) = LOWER(?) LIMIT 1", + 'SELECT n.nspname + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.oid = pg_my_temp_schema() + AND lower(c.relname) = lower(?) + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', array( $table_name ) ); + } catch ( PDOException $e ) { + if ( 'sqlite' !== $pdo_driver_name ) { + throw $e; + } - return false === $stmt->fetchColumn() ? null : 'temp'; + return $this->get_active_sqlite_temporary_table_schema( $table_name ); } + $schema_name = $stmt->fetchColumn(); + return false === $schema_name ? null : (string) $schema_name; + } + + /** + * Get the active SQLite temporary schema for a table name. + * + * @param string $table_name Table name. + * @return string|null Temporary schema name, or null when no active temporary table exists. + */ + private function get_active_sqlite_temporary_table_schema( string $table_name ): ?string { $stmt = $this->connection->query( - 'SELECT 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', + "SELECT name FROM sqlite_temp_master WHERE type = 'table' AND LOWER(name) = LOWER(?) LIMIT 1", array( $table_name ) ); - $schema_name = $stmt->fetchColumn(); - return false === $schema_name ? null : (string) $schema_name; + return false === $stmt->fetchColumn() ? null : 'temp'; } /** @@ -7356,21 +7558,26 @@ private function is_mysql_null_rejected_join_alias_predicate( array $tokens, int private function translate_simple_mysql_delete_query( string $query ): ?string { $tokens = $this->get_mysql_tokens( $query ); if ( - ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id - || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id ) { return null; } - $table_name = $this->get_mysql_identifier_token_value( $tokens[2] ); + $position = 2; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } - $statement_end = $this->get_mysql_statement_end_position( $tokens, 4 ); - if ( null === $statement_end || 4 >= $statement_end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $where_position = $position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $where_position + 1 ); + if ( null === $statement_end || $where_position + 1 >= $statement_end ) { return null; } @@ -7383,14 +7590,14 @@ private function translate_simple_mysql_delete_query( string $query ): ?string { WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, WP_MySQL_Lexer::USING_SYMBOL, ); - if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, $unsupported_tokens ) ) { + if ( $this->contains_top_level_mysql_token( $tokens, $where_position + 1, $statement_end, $unsupported_tokens ) ) { return null; } return sprintf( 'DELETE FROM %s WHERE %s', $this->connection->quote_identifier( $table_name ), - $this->translate_mysql_token_sequence_to_postgresql( $tokens, 4, $statement_end ) + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $statement_end ) ); } @@ -7417,12 +7624,11 @@ private function translate_mysql_on_duplicate_key_update_query( string $query ): } ++$position; - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } - ++$position; $columns = $this->parse_mysql_identifier_list( $tokens, $position ); if ( null === $columns ) { return null; @@ -7835,12 +8041,11 @@ private function translate_simple_mysql_replace_query( string $query ): ?array { } ++$position; - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } - ++$position; $columns = $this->parse_mysql_identifier_list( $tokens, $position ); if ( null === $columns ) { return null; @@ -8028,12 +8233,11 @@ private function translate_simple_mysql_insert_query( string $query ): ?array { } ++$position; - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } - ++$position; $columns = $this->parse_mysql_identifier_list( $tokens, $position ); if ( null === $columns ) { return null; @@ -8103,12 +8307,18 @@ private function translate_simple_mysql_insert_select_query( string $query ): ?a } ++$position; - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $table_reference_start = $position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } + $table_reference_end = $position; + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_reference_end + ); - ++$position; $columns = $this->parse_mysql_identifier_list( $tokens, $position ); if ( null === $columns ) { return null; @@ -8121,7 +8331,13 @@ private function translate_simple_mysql_insert_select_query( string $query ): ?a $select_start = $position; $select_end = $statement_end; - $outer_replacements = array(); + $outer_replacements = array( + array( + 'start' => $table_reference_start, + 'end' => $table_reference_end, + 'sql' => $table_reference_sql, + ), + ); $closing_replacement = array(); if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); @@ -8875,12 +9091,11 @@ private function translate_simple_mysql_update_query( string $query ): ?string { } $position = 1; - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } - ++$position; if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position ]->id ) { return null; } @@ -9664,13 +9879,18 @@ private function translate_simple_mysql_select_query( string $query ): ?string { return null; } - $table_token = $tokens[ $from_position + 1 ] ?? null; - $table_name = $this->get_mysql_identifier_token_value( $table_token ); + $table_reference_start = $from_position + 1; + $position = $table_reference_start; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); if ( null === $table_name ) { return null; } + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $position + ); - $position = $from_position + 2; $where_position = null; $where_end = null; $order_position = null; @@ -9706,7 +9926,7 @@ private function translate_simple_mysql_select_query( string $query ): ?string { $sql = sprintf( 'SELECT %s FROM %s', $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $from_position ), - $this->translate_mysql_identifier_token_to_postgresql( $table_token ) + $table_reference_sql ); $scope = $this->get_mysql_single_table_scope( $table_name ); @@ -17466,6 +17686,53 @@ private function get_mysql_select_scope( array $tokens, int $start, int $end ): return empty( $scope['tables'] ) || $expect_next ? null : $scope; } + /** + * Parse an unqualified or main database-qualified MySQL table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table-name start position, updated on success. + * @return string|null Table name, or null when unsupported. + */ + private function parse_mysql_main_database_table_name( array $tokens, int &$position ): ?string { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return $first_identifier; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name || 0 !== strcasecmp( $first_identifier, $this->main_db_name ) ) { + return null; + } + + $position += 2; + return $table_name; + } + + /** + * Get PostgreSQL SQL for an unqualified or main database-qualified table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token position. + * @param int $end Final table-reference token position, exclusive. + * @return string PostgreSQL table reference SQL. + */ + private function get_mysql_main_database_table_reference_sql( array $tokens, int $start, int $end ): string { + if ( + $start + 3 === $end + && isset( $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + ) { + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ); + } + + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start ] ?? null ); + } + /** * Parse a simple table reference and optional alias. * @@ -20486,9 +20753,73 @@ private function is_create_table_query( string $query ): bool { ++$position; } + $table_name_position = $position + 1; + return isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id - && $this->has_mysql_create_table_marker( $tokens ); + && ( + $this->has_mysql_create_table_marker( $tokens ) + || $this->is_mysql_create_table_qualified_target( $tokens, $table_name_position ) + ); + } + + /** + * Validate a CREATE TABLE target database qualifier. + * + * @param string $query MySQL query. + */ + private function validate_mysql_create_table_target_database( string $query ): void { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name || ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + } + + /** + * Check whether a CREATE TABLE statement uses a qualified table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table-name position. + * @return bool Whether the table target is qualified. + */ + private function is_mysql_create_table_qualified_target( array $tokens, int $position ): bool { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + return null !== $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $position + 2 ] ?? null ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php index 5bc4d2dd4..e6715ba57 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection.php @@ -9,7 +9,7 @@ class WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection extends WP_PostgreS * * @return string PDO driver name. */ - protected function get_driver_name(): string { + public function get_driver_name(): string { return 'pgsql'; } } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 0d35ae028..3e658faaa 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -8217,6 +8217,144 @@ public function test_mysql_transaction_control_statements_use_fast_backend_path( $this->assertSame( 0, $driver->get_last_column_count() ); } + /** + * Tests public SAVEPOINT statements return MySQL-compatible results. + */ + public function test_mysql_savepoint_statements_use_public_query_path(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $driver->query( 'CREATE TABLE savepoint_public (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'INSERT INTO savepoint_public VALUES (1)' ); + $driver->query( 'SELECT 1 AS warm_read' ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK TO SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'RELEASE SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS row_count FROM savepoint_public' ); + + $this->assertSame( '0', $rows[0]->row_count ); + } + + /** + * Tests unsupported savepoint-family statements fail before raw backend execution. + */ + public function test_unsupported_mysql_savepoint_statements_fail_closed_without_backend_execution(): void { + $cases = array( + 'RELEASE s', + 'ROLLBACK WORK TO SAVEPOINT s', + 'ROLLBACK WORK TO s', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $driver->query( 'SAVEPOINT s' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SAVEPOINT statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SAVEPOINT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests TRUNCATE TABLE removes rows and returns empty result metadata. + */ + public function test_mysql_truncate_table_removes_rows_and_returns_empty_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE truncate_test (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->query( "INSERT INTO truncate_test (value) VALUES ('before')" ); + $driver->query( "INSERT INTO truncate_test (value) VALUES ('again')" ); + + $this->assertSame( 0, $driver->query( 'TRUNCATE TABLE truncate_test' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'DELETE FROM "truncate_test"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS row_count FROM truncate_test' ); + $this->assertSame( '0', $rows[0]->row_count ); + + $driver->query( "INSERT INTO truncate_test (value) VALUES ('after')" ); + $rows = $driver->query( 'SELECT id, value FROM truncate_test' ); + + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'after', + ), + ), + $rows + ); + } + + /** + * Tests main database-qualified table names work for a focused basic operation slice. + */ + public function test_main_database_qualified_table_names_work_for_basic_table_operations(): void { + $driver = $this->create_driver( 'wp' ); + + $this->assertSame( 0, $driver->query( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ) ); + $this->assertStringStartsWith( 'CREATE TABLE "t"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO wp.t (id) VALUES (1)' ) ); + $this->assertSame( 'INSERT INTO "t" ("id") VALUES (1)', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $rows ); + $this->assertSame( 'SELECT * FROM t', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'UPDATE wp.t SET id = 2' ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $rows ); + + $this->assertSame( 1, $driver->query( 'DELETE FROM wp.t WHERE id = 2' ) ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertSame( array(), $rows ); + + $driver->query( 'INSERT INTO wp.t (id) VALUES (3)' ); + $this->assertSame( 0, $driver->query( 'TRUNCATE TABLE wp.t' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'DELETE FROM "t"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests malformed main database-qualified CREATE TABLE targets fail closed. + */ + public function test_create_table_rejects_extra_qualified_main_database_target(): void { + $driver = $this->create_driver( 'wp' ); + + try { + $driver->query( 'CREATE TABLE wp.other.t (id INT)' ); + $this->fail( 'Expected extra qualified CREATE TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests UNLOCK TABLES forms are MySQL compatibility no-ops. */ @@ -9047,23 +9185,7 @@ private function create_driver_with_postgresql_quote_translation(): WP_PostgreSQ * @return WP_PostgreSQL_Connection Connection fixture. */ private function create_table_administration_catalog_fixture_connection(): WP_PostgreSQL_Connection { - $pdo = new class( 'sqlite::memory:' ) extends PDO { - /** - * Report PostgreSQL for branch selection while keeping SQLite execution available. - * - * @param int $attribute PDO attribute. - * @return mixed Attribute value. - */ - #[\ReturnTypeWillChange] - public function getAttribute( $attribute ) { - if ( PDO::ATTR_DRIVER_NAME === $attribute ) { - return 'pgsql'; - } - - return parent::getAttribute( $attribute ); - } - }; - + $pdo = new PDO( 'sqlite::memory:' ); return new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { /** * PostgreSQL catalog existence queries issued by the driver. @@ -9159,6 +9281,15 @@ public function query( string $sql, array $params = array() ): PDOStatement { public function get_table_administration_catalog_queries(): array { return $this->table_administration_catalog_queries; } + + /** + * Report PostgreSQL for branch selection while keeping SQLite execution available. + * + * @return string Driver name. + */ + public function get_driver_name(): string { + return 'pgsql'; + } }; } From c73a7c757af19f0b21b93a9c887d63ff90704cd5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 18:34:34 +0000 Subject: [PATCH 127/142] Support more PostgreSQL query forms Add standalone CREATE/DROP INDEX support, including fail-closed handling for unsupported forms. Support main database-qualified DROP TABLE and database qualifiers for SHOW TABLES and SHOW INDEX/INDEXES/KEYS. --- .../postgresql/class-wp-postgresql-driver.php | 669 +++++++++++++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 330 ++++++++- 2 files changed, 957 insertions(+), 42 deletions(-) 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 index 603690bdb..8e1092b71 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -539,6 +539,14 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $result; } + $create_index_query = $this->translate_mysql_create_index_query( $query ); + if ( null !== $create_index_query ) { + $this->execute_postgresql_statements( $create_index_query['statements'] ); + $this->apply_mysql_create_index_metadata( $create_index_query['metadata'] ); + $this->last_result = 0; + return $this->last_result; + } + $alter_query = $this->translate_mysql_dbdelta_alter_table_query( $query ); if ( null !== $alter_query ) { $result = $this->execute_postgresql_statements( $alter_query['statements'] ); @@ -548,14 +556,19 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $drop_query = $this->translate_mysql_drop_table_query( $query ); if ( null !== $drop_query ) { - $metadata_targets = $this->get_mysql_schema_metadata_drop_targets( - $drop_query['tables'], - $drop_query['temporary'] - ); - $result = $this->execute_postgresql_statements( $drop_query['statements'] ); + $this->execute_postgresql_statements( $drop_query['statements'] ); $this->maybe_clear_mysql_schema_metadata_table_state( $drop_query['tables'] ); - $this->delete_mysql_schema_metadata_for_table_targets( $metadata_targets ); - return $result; + $this->delete_mysql_schema_metadata_for_table_targets( $drop_query['metadata_targets'] ); + $this->last_result = 0; + return $this->last_result; + } + + $drop_index_query = $this->translate_mysql_drop_index_query( $query ); + if ( null !== $drop_index_query ) { + $this->execute_postgresql_statements( $drop_index_query['statements'] ); + $this->apply_mysql_drop_index_metadata( $drop_index_query['metadata'] ); + $this->last_result = 0; + return $this->last_result; } $describe_table_name = $this->get_describe_table_name( $query ); @@ -567,6 +580,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( null !== $show_tables_query ) { return $this->execute_show_tables_query( $show_tables_query['full'], + $show_tables_query['schema'], + $show_tables_query['database'], $show_tables_query['like'], $fetch_mode, ...$fetch_mode_args @@ -606,6 +621,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $show_index_query = $this->get_show_index_query( $query ); if ( null !== $show_index_query ) { return $this->execute_show_index_query( + $show_index_query['schema'], $show_index_query['table'], $show_index_query['key_name'], $fetch_mode, @@ -2513,6 +2529,38 @@ private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { } } + /** + * Store MySQL metadata for a standalone CREATE INDEX statement. + * + * @param array $metadata CREATE INDEX metadata. + */ + private function apply_mysql_create_index_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $metadata['schema']; + $table_name = $metadata['table']; + $index = $metadata['index']; + + $this->delete_mysql_index_metadata( $table_schema, $table_name, $index['name'] ); + $index['ordinal'] = $this->get_next_mysql_index_ordinal( $table_schema, $table_name ); + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index ); + } + + /** + * Remove MySQL metadata for a standalone DROP INDEX statement. + * + * @param array $metadata DROP INDEX metadata. + */ + private function apply_mysql_drop_index_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $this->delete_mysql_index_metadata( + $metadata['schema'], + $metadata['table'], + $metadata['index'] + ); + } + /** * Insert or replace column metadata. * @@ -2694,6 +2742,25 @@ private function get_existing_mysql_column_ordinal( string $table_schema, string return false === $ordinal ? null : (int) $ordinal; } + /** + * Get the next stored index ordinal for a table. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_index_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(index_ordinal), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + /** * Get stored nullable metadata for an index column. * @@ -2828,6 +2895,348 @@ private function mysql_table_has_column_metadata( string $table_schema, string $ return $this->mysql_table_has_column_metadata_cache[ $cache_key ]; } + /** + * Translate supported standalone MySQL CREATE INDEX statements to PostgreSQL. + * + * @param string $query MySQL CREATE INDEX query. + * @return array{statements: string[], metadata: array}|null Translation, or null when this is not CREATE INDEX. + */ + private function translate_mysql_create_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $position = 1; + $is_unique = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id ) { + $is_unique = true; + ++$position; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $position ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $index_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + 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 = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'CREATE INDEX' ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $key_list_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $key_list_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $key_parts = $this->parse_mysql_create_index_key_parts( + $tokens, + $position + 1, + $key_list_end - 1, + $is_unique + ); + if ( null === $key_parts ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $position = $key_list_end; + if ( ! $this->consume_mysql_supported_create_index_options( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $table_name = $table_reference['table']; + $postgresql_index = $this->connection->quote_identifier( $table_name . '__' . $index_name ); + $postgresql_table = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + + return array( + 'statements' => array( + sprintf( + 'CREATE %sINDEX %s ON %s (%s)', + $is_unique ? 'UNIQUE ' : '', + $postgresql_index, + $postgresql_table, + implode( ', ', $key_parts['sql'] ) + ), + ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => array( + 'name' => $index_name, + 'non_unique' => $is_unique ? '0' : '1', + 'index_type' => 'BTREE', + 'columns' => $key_parts['metadata'], + ), + ), + ); + } + + /** + * Consume a supported MySQL CREATE INDEX USING clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return bool Whether the optional index type is supported. + */ + private function consume_mysql_supported_create_index_type( array $tokens, int &$position ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::USING_SYMBOL !== $tokens[ $position ]->id ) { + return true; + } + + if ( ! isset( $tokens[ $position + 1 ] ) || ! $this->is_mysql_token_value( $tokens[ $position + 1 ], 'btree' ) ) { + return false; + } + + $position += 2; + return true; + } + + /** + * Parse standalone CREATE INDEX key parts. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First key-part token position. + * @param int $end Final key-part token position, exclusive. + * @param bool $is_unique Whether the index is unique. + * @return array{sql: string[], metadata: array[]}|null Key part SQL and metadata, or null when unsupported. + */ + private function parse_mysql_create_index_key_parts( array $tokens, int $start, int $end, bool $is_unique ): ?array { + $key_part_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $key_part_ranges || array() === $key_part_ranges ) { + return null; + } + + $sql_parts = array(); + $metadata_parts = array(); + foreach ( $key_part_ranges as $key_part_range ) { + $position = $key_part_range['start']; + $column_name = $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name ) { + return null; + } + + ++$position; + $sub_part = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + if ( $is_unique ) { + return null; + } + + $sub_part = $tokens[ $position + 1 ]->get_value(); + $position += 3; + } + + $direction = ''; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $position ]->id + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + } + + if ( $position !== $key_part_range['end'] ) { + return null; + } + + $sql_parts[] = $this->connection->quote_identifier( $column_name ) . $direction; + $metadata_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $metadata_parts ) + 1, + 'sub_part' => $sub_part, + ); + } + + return array( + 'sql' => $sql_parts, + 'metadata' => $metadata_parts, + ); + } + + /** + * Get a column identifier token value from a CREATE INDEX key part. + * + * MySQL permits some unquoted keyword-like names, such as "value" and + * "name", in key parts. Keep this fallback local to index column parsing so + * statement structure keywords are still handled explicitly by the parser. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null when unsupported. + */ + private function get_mysql_index_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( null === $token ) { + return null; + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::ASC_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::DESC_SYMBOL, + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::ON_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ), + true + ) + ) { + return null; + } + + $value = $token->get_value(); + if ( 1 === preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $value ) ) { + return $value; + } + + return null; + } + + /** + * Consume supported MySQL CREATE INDEX options. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether all remaining options are supported. + */ + private function consume_mysql_supported_create_index_options( array $tokens, int &$position, int $statement_end ): bool { + while ( $position < $statement_end ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::COMMENT_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + ) { + $position += 2; + continue; + } + + $before_type_position = $position; + if ( ! $this->consume_mysql_supported_create_index_type( $tokens, $position ) ) { + return false; + } + if ( $position !== $before_type_position ) { + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::VISIBLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + return $position === $statement_end; + } + + return true; + } + + /** + * Resolve the backend schema for a writable MySQL table reference. + * + * @param array $table_reference Parsed table reference. + * @param string $statement_type Statement type for error messages. + * @return string Backend schema name. + */ + private function get_mysql_writable_table_backend_schema( array $table_reference, string $statement_type ): string { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + + if ( null === $requested_schema ) { + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + return $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + } + + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( + 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ) + ) { + return 'public'; + } + + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + /** + * Build a backend identifier in a specific schema when the test backend supports it. + * + * @param string $schema_name Backend schema name. + * @param string $object_name Object name. + * @return string Backend SQL identifier. + */ + private function get_postgresql_schema_identifier( string $schema_name, string $object_name ): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( + 'sqlite' === $driver_name + && ( + 'main' === $schema_name + || ( 'public' === $schema_name && ! $this->sqlite_database_schema_exists( 'public' ) ) + ) + ) { + return $this->connection->quote_identifier( $object_name ); + } + + return $this->connection->quote_identifier( $schema_name ) . '.' . $this->connection->quote_identifier( $object_name ); + } + /** * Translate supported dbDelta ALTER TABLE statements to PostgreSQL. * @@ -3206,36 +3615,166 @@ private function is_postgresql_integer_family_data_type( string $data_type ): bo * Translate supported DROP TABLE statements and expose dropped table names. * * @param string $query MySQL DROP TABLE query. - * @return array{statements: string[], tables: string[], temporary: bool}|null Translation, or null when unsupported. + * @return array{statements: string[], tables: string[], metadata_targets: array[]}|null Translation, or null when unsupported. */ private function translate_mysql_drop_table_query( string $query ): ?array { - if ( ! preg_match( '/^\s*DROP\s+(?PTEMPORARY\s+)?TABLE\s+(?PIF\s+EXISTS\s+)?(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { return null; } - $table_names = $this->parse_mysql_identifier_csv( $matches['tables'] ); - if ( null === $table_names ) { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $position = 1; + $temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { return null; } - $temporary = ! empty( $matches['temporary'] ); + ++$position; + $if_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $if_exists = true; + $position += 2; + } + + $table_names = array(); $table_identifiers = array(); - foreach ( $table_names as $table_name ) { - $table_identifiers[] = $temporary - ? $this->get_temporary_drop_table_identifier( $table_name ) - : $this->connection->quote_identifier( $table_name ); + $metadata_targets = array(); + while ( $position < $statement_end ) { + $reference_start = $position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $table_name = $table_reference['table']; + $table_names[] = $table_name; + + if ( $temporary ) { + if ( null !== $table_reference['schema'] ) { + $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP TABLE' ); + } + + $table_identifiers[] = $this->get_temporary_drop_table_identifier( $table_name ); + foreach ( $this->get_mysql_schema_metadata_drop_targets( array( $table_name ), true ) as $target ) { + $metadata_targets[] = $target; + } + } else { + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP TABLE' ); + $table_identifiers[] = null === $table_reference['schema'] + ? $this->connection->quote_identifier( $table_name ) + : $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $metadata_targets[] = array( + 'schema' => $table_schema, + 'table' => $table_name, + ); + } + + if ( $position === $reference_start ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + if ( $position === $statement_end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + ++$position; + if ( $position === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + } + + if ( array() === $table_names ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $statements = array(); + foreach ( $table_identifiers as $table_identifier ) { + $statements[] = sprintf( + 'DROP TABLE %s%s', + $if_exists ? 'IF EXISTS ' : '', + $table_identifier + ); + } + + return array( + 'statements' => $statements, + 'tables' => $table_names, + 'metadata_targets' => $metadata_targets, + ); + } + + /** + * Translate supported standalone MySQL DROP INDEX statements to PostgreSQL. + * + * @param string $query MySQL DROP INDEX query. + * @return array{statements: string[], metadata: array}|null Translation, or null when this is not DROP INDEX. + */ + private function translate_mysql_drop_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + $position = 2; + $index_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $index_name ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || $position !== $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); } + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP INDEX' ); + $table_name = $table_reference['table']; + return array( 'statements' => array( - sprintf( - 'DROP TABLE %s%s', - ! empty( $matches['if_exists'] ) ? 'IF EXISTS ' : '', - implode( ', ', $table_identifiers ) - ), + 'DROP INDEX ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $index_name ), + ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => $index_name, ), - 'tables' => $table_names, - 'temporary' => $temporary, ); } @@ -3621,7 +4160,7 @@ private function get_describe_table_name( string $query ): ?string { * Parse a supported MySQL SHOW TABLES statement. * * @param string $query MySQL query. - * @return array{full: bool, like: string|null}|null SHOW TABLES options, or null when unsupported. + * @return array{full: bool, schema: string, database: string, like: string|null}|null SHOW TABLES options, or null when unsupported. */ private function get_show_tables_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -3641,6 +4180,31 @@ private function get_show_tables_query( string $query ): ?array { } ++$position; + $schema_name = 'public'; + $database_name = $this->db_name; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + $database_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $database_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + if ( + 0 !== strcasecmp( $database_name, $this->main_db_name ) + && 0 !== strcasecmp( $database_name, 'public' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $schema_name = 'public'; + $position += 2; + } + $like = null; if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { if ( @@ -3662,8 +4226,10 @@ private function get_show_tables_query( string $query ): ?array { } return array( - 'full' => $is_full, - 'like' => $like, + 'full' => $is_full, + 'schema' => $schema_name, + 'database' => $database_name, + 'like' => $like, ); } @@ -4308,7 +4874,7 @@ private function get_show_columns_table_reference( array $tokens, int $position * family so unsupported forms fail before raw backend execution. * * @param string $query MySQL query. - * @return array{table: string, key_name: string|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. + * @return array{schema: string, table: string, key_name: string|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. */ private function get_show_index_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -4343,13 +4909,35 @@ private function get_show_index_query( string $query ): ?array { throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); - if ( null === $table_name ) { + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } - $position += 2; - $key_name = null; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + if ( null !== $table_reference['schema'] ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $table_reference['schema'] = $schema_name; + $position += 2; + } + + $schema_name = $this->get_mysql_writable_table_backend_schema( $table_reference, 'SHOW INDEX' ); + $table_name = $table_reference['table']; + $key_name = null; if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { $where_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); if ( @@ -4374,6 +4962,7 @@ private function get_show_index_query( string $query ): ?array { } return array( + 'schema' => $schema_name, 'table' => $table_name, 'key_name' => $key_name, ); @@ -4918,14 +5507,16 @@ private function execute_mysql_use_statement( string $database_name ): int { /** * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. * - * @param bool $is_full Whether this is SHOW FULL TABLES. - * @param string|null $like Optional MySQL LIKE pattern. - * @param int $fetch_mode PDO fetch mode. + * @param bool $is_full Whether this is SHOW FULL TABLES. + * @param string $schema_name Backend schema name. + * @param string $database_name MySQL-facing database name. + * @param string|null $like Optional MySQL LIKE pattern. + * @param int $fetch_mode PDO fetch mode. * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed SHOW TABLES result rows. */ - private function execute_show_tables_query( bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { - $table_column = $this->connection->quote_identifier( 'Tables_in_' . $this->db_name ); + private function execute_show_tables_query( bool $is_full, string $schema_name, string $database_name, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + $table_column = $this->connection->quote_identifier( 'Tables_in_' . $database_name ); $sql = sprintf( 'SELECT table_name AS %s%s FROM information_schema.tables @@ -4938,7 +5529,7 @@ private function execute_show_tables_query( bool $is_full, ?string $like, $fetch $this->connection->quote( self::MYSQL_INDEX_METADATA_TABLE ), $this->connection->quote( self::MYSQL_CHARSET_METADATA_TABLE ) ); - $params = array( 'public' ); + $params = array( $schema_name ); if ( null !== $like ) { $sql .= " AND table_name LIKE ? ESCAPE '\\'"; @@ -6357,10 +6948,10 @@ private function get_default_mysql_collation_for_charset( string $charset ): str * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed SHOW INDEX result rows. */ - private function execute_show_index_query( string $table_name, ?string $key_name, $fetch_mode, ...$fetch_mode_args ) { + private function execute_show_index_query( string $schema_name, string $table_name, ?string $key_name, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); - $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); $cache_key = $this->get_mysql_introspection_result_cache_key( 'show_index', $fetch_mode, diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 3e658faaa..2218915f8 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1045,6 +1045,261 @@ public function test_unqualified_drop_table_removes_temporary_metadata_without_c $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop' ) ); } + /** + * Tests standalone CREATE INDEX updates PostgreSQL schema and MySQL metadata. + */ + public function test_standalone_create_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX idx_value ON wptests_standalone_index (value(16) DESC) COMMENT "Lookup"' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_standalone_index__idx_value" ON "wptests_standalone_index" ("value" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_index' ); + $this->assertSame( array( 'PRIMARY', 'idx_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + $this->assertSame( '16', $indexes[1]['sub_part'] ); + $this->assertSame( '', $indexes[1]['nullable'] ); + } + + /** + * Tests standalone CREATE INDEX keeps the index name unqualified for PostgreSQL. + */ + public function test_standalone_create_index_with_public_schema_qualifies_table_only(): void { + $driver = $this->create_driver(); + $connection = $driver->get_connection(); + $pdo = $connection->get_pdo(); + + $pdo->exec( "ATTACH DATABASE ':memory:' AS public" ); + $pdo->exec( + sprintf( + 'CREATE TABLE %s.%s (%s TEXT)', + $connection->quote_identifier( 'public' ), + $connection->quote_identifier( 'wptests_public_index' ), + $connection->quote_identifier( 'value' ) + ) + ); + + $translate_create_index = new ReflectionMethod( WP_PostgreSQL_Driver::class, 'translate_mysql_create_index_query' ); + if ( PHP_VERSION_ID < 80100 ) { + $translate_create_index->setAccessible( true ); + } + + $translation = $translate_create_index->invoke( + $driver, + 'CREATE INDEX idx_value ON wptests_public_index (value)' + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + array( + 'CREATE INDEX "wptests_public_index__idx_value" ON "public"."wptests_public_index" ("value")', + ), + $translation['statements'] + ); + } + + /** + * Tests standalone CREATE UNIQUE INDEX participates in ON DUPLICATE KEY UPDATE translation. + */ + public function test_standalone_create_unique_index_updates_upsert_conflict_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_standalone_unique_index (slug varchar(191) NOT NULL, value int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_unique_index ( + slug varchar(191) NOT NULL, + value int NOT NULL + )' + ); + $driver->query( 'CREATE UNIQUE INDEX slug_lookup ON wptests_standalone_unique_index (slug)' ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO wptests_standalone_unique_index (`slug`, `value`) + VALUES ('alpha', 1) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)" + ) + ); + + $this->assertSame( + 'INSERT INTO "wptests_standalone_unique_index" ("slug", "value") VALUES (\'alpha\', 1) ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests standalone DROP INDEX removes PostgreSQL schema and MySQL metadata. + */ + public function test_standalone_drop_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_drop_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_drop_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'CREATE INDEX idx_value ON wptests_standalone_drop_index (value)' ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_value ON wptests_standalone_drop_index' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_standalone_drop_index__idx_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_drop_index' ); + $this->assertSame( array( 'PRIMARY' ), array_column( $indexes, 'key_name' ) ); + } + + /** + * Tests main database-qualified standalone index statements target public table metadata. + */ + public function test_standalone_index_accepts_main_database_qualified_table_names(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_qualified_index (id int NOT NULL, name varchar(191) NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_qualified_index ( + id int NOT NULL, + name varchar(191) NOT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'CREATE INDEX idx_name ON wptests.wptests_qualified_index (name)' ) ); + $this->assertSame( + 'CREATE INDEX "wptests_qualified_index__idx_name" ON "wptests_qualified_index" ("name")', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertSame( + array( 'idx_name' ), + array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_index' ), 'key_name' ) + ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_name ON wptests.wptests_qualified_index' ) ); + $this->assertSame( + 'DROP INDEX "wptests_qualified_index__idx_name"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_index' ) ); + } + + /** + * Tests main database-qualified DROP TABLE removes tables and MySQL metadata. + */ + public function test_drop_table_accepts_main_database_qualified_table_names(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_qualified_drop_one (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->query( 'CREATE TABLE wptests_qualified_drop_two (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_qualified_drop_one (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_qualified_drop_two (id int NOT NULL, PRIMARY KEY (id))' ); + + $this->assertNotSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_qualified_drop_one' ) ); + $this->assertNotSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_drop_two' ) ); + + $this->assertSame( + 0, + $driver->query( 'DROP TABLE IF EXISTS wptests.wptests_qualified_drop_one, wptests.wptests_qualified_drop_two' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE IF EXISTS "wptests_qualified_drop_one"', + 'params' => array(), + ), + array( + 'sql' => 'DROP TABLE IF EXISTS "wptests_qualified_drop_two"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertFalse( $this->sqlite_table_exists( $driver, 'main', 'wptests_qualified_drop_one' ) ); + $this->assertFalse( $this->sqlite_table_exists( $driver, 'main', 'wptests_qualified_drop_two' ) ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_qualified_drop_one' ) ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_drop_two' ) ); + } + + /** + * Tests unsupported standalone index DDL fails before backend execution. + */ + public function test_standalone_index_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'CREATE FULLTEXT INDEX idx_value ON wptests_index_fail (value)', + 'CREATE INDEX idx_value USING HASH ON wptests_index_fail (value)', + 'CREATE UNIQUE INDEX idx_value ON wptests_index_fail (value(16))', + 'CREATE INDEX idx_value ON information_schema.tables (name)', + 'CREATE INDEX idx_value ON other_db.wptests_index_fail (value)', + 'DROP INDEX `PRIMARY` ON wptests_index_fail', + 'DROP INDEX idx_value ON information_schema.tables', + 'DROP INDEX idx_value ON other_db.wptests_index_fail', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_index_fail (value varchar(255) NOT NULL)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported standalone index DDL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertContains( + $e->getMessage(), + array( + 'Unsupported CREATE INDEX statement.', + 'Unsupported DROP INDEX statement.', + 'Unsupported information_schema query.', + ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests plain CHAR columns do not route through the MySQL DDL translator. */ @@ -6718,6 +6973,48 @@ public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { $this->assertSame( 'Table_type', $driver->get_last_column_meta()[1]['name'] ); } + /** + * Tests SHOW TABLES accepts current database qualification forms. + */ + public function test_show_tables_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW TABLES FROM wptests' => array( 'Tables_in_wptests', 3, array( 'public' ) ), + "SHOW TABLES IN `wptests` LIKE 'wptests_%'" => array( 'Tables_in_wptests', 3, array( 'public', 'wptests_%' ) ), + "SHOW FULL TABLES FROM wptests LIKE 'wptests_%'" => array( 'Tables_in_wptests', 3, array( 'public', 'wptests_%' ) ), + ); + + foreach ( $cases as $query => $expected ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertCount( $expected[1], $tables, $query ); + $this->assertSame( $expected[0], $driver->get_last_column_meta()[0]['name'], $query ); + $this->assertSame( 'wptests_options', $tables[0]->{$expected[0]}, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'], $query ); + $this->assertSame( $expected[2], $queries[0]['params'], $query ); + } + } + + /** + * Tests unsupported SHOW TABLES database qualifiers fail before backend execution. + */ + public function test_show_tables_unsupported_database_qualification_does_not_reach_backend(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SHOW TABLES FROM other_db' ); + $this->fail( 'Expected unsupported SHOW TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + /** * Tests SHOW TABLES hides internal PostgreSQL metadata tables. */ @@ -7876,11 +8173,38 @@ public function test_show_keys_where_key_name_filters_catalog_rows(): void { } /** - * Tests unsupported SHOW KEYS clauses fail before reaching the backend. + * Tests SHOW INDEX-family statements accept current database qualification forms. + */ + public function test_show_index_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW INDEX FROM wptests.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW INDEXES FROM wptests_options FROM wptests' => array( 'public', 'wptests_options' ), + "SHOW KEYS FROM wptests_options IN `wptests` WHERE Key_name = 'autoload'" => array( 'public', 'wptests_options', 'autoload' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( $query ); + + $this->assertNotCount( 0, $indexes, $query ); + $this->assertSame( 'wptests_options', $indexes[0]->Table, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'], $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + + /** + * Tests unsupported SHOW INDEX-family clauses fail before reaching the backend. */ - public function test_show_keys_unsupported_syntax_does_not_reach_backend(): void { + public function test_show_index_family_unsupported_syntax_does_not_reach_backend(): void { $queries = array( + 'SHOW INDEX FROM other_db.wptests_options FROM wptests', 'SHOW KEYS IN wptests_options', + 'SHOW KEYS FROM other_db.wptests_options IN wptests', 'SHOW KEYS FROM wptests_options WHERE Non_unique = 0', 'SHOW KEYS FROM wptests_options LIMIT 1', ); @@ -7890,7 +8214,7 @@ public function test_show_keys_unsupported_syntax_does_not_reach_backend(): void try { $driver->query( $query ); - $this->fail( 'Expected unsupported SHOW KEYS statement to throw.' ); + $this->fail( 'Expected unsupported SHOW INDEX statement to throw.' ); } catch ( InvalidArgumentException $e ) { $this->assertSame( 'Unsupported SHOW INDEX statement.', $e->getMessage(), $query ); $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); From fa7a5d0cfd88be979e8ed1a75e63a28852f1daf1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 20:11:16 +0000 Subject: [PATCH 128/142] Support PostgreSQL metadata SHOW filters Support qualified DESCRIBE and DESC metadata statements, and add safe exact SHOW ... WHERE filters for PostgreSQL-backed metadata statements. --- .../postgresql/class-wp-postgresql-driver.php | 258 +++++++++++++++--- ...L_Driver_Show_Index_Fixture_Connection.php | 44 ++- .../tests/WP_PostgreSQL_Driver_Tests.php | 258 ++++++++++++++++-- 3 files changed, 501 insertions(+), 59 deletions(-) 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 index 8e1092b71..4c1e88c33 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -571,9 +571,14 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->last_result; } - $describe_table_name = $this->get_describe_table_name( $query ); - if ( null !== $describe_table_name ) { - return $this->execute_describe_query( $describe_table_name, $fetch_mode, ...$fetch_mode_args ); + $describe_table_reference = $this->get_describe_table_reference( $query ); + if ( null !== $describe_table_reference ) { + return $this->execute_describe_query( + $describe_table_reference['schema'], + $describe_table_reference['table'], + $fetch_mode, + ...$fetch_mode_args + ); } $show_tables_query = $this->get_show_tables_query( $query ); @@ -583,6 +588,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $show_tables_query['schema'], $show_tables_query['database'], $show_tables_query['like'], + $show_tables_query['where'], $fetch_mode, ...$fetch_mode_args ); @@ -613,6 +619,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo $show_columns_query['table'], $show_columns_query['full'], $show_columns_query['like'], + $show_columns_query['where'], $fetch_mode, ...$fetch_mode_args ); @@ -623,7 +630,7 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo return $this->execute_show_index_query( $show_index_query['schema'], $show_index_query['table'], - $show_index_query['key_name'], + $show_index_query['where'], $fetch_mode, ...$fetch_mode_args ); @@ -4131,12 +4138,12 @@ private function get_mysql_use_database_name( string $query ): ?string { } /** - * Get the table name from a supported MySQL DESCRIBE/DESC statement. + * Get the table reference from a supported MySQL DESCRIBE/DESC statement. * * @param string $query MySQL query. - * @return string|null Table name, or null when the statement is unsupported. + * @return array{schema: string, table: string}|null Table reference, or null when this is not DESCRIBE/DESC. */ - private function get_describe_table_name( string $query ): ?string { + private function get_describe_table_reference( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); if ( ! isset( $tokens[0] ) @@ -4148,19 +4155,23 @@ private function get_describe_table_name( string $query ): ?string { return null; } - $table_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); - if ( null === $table_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { - return null; + $position = 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported DESCRIBE statement.' ); } - return $table_name; + return array( + 'schema' => $this->get_mysql_writable_table_backend_schema( $table_reference, 'DESCRIBE' ), + 'table' => $table_reference['table'], + ); } /** * Parse a supported MySQL SHOW TABLES statement. * * @param string $query MySQL query. - * @return array{full: bool, schema: string, database: string, like: string|null}|null SHOW TABLES options, or null when unsupported. + * @return array{full: bool, schema: string, database: string, like: string|null, where: array{column: string, value: string}|null}|null SHOW TABLES options, or null when unsupported. */ private function get_show_tables_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -4221,6 +4232,27 @@ private function get_show_tables_query( string $query ): ?array { $position += 2; } + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + if ( null !== $like ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $allowed_columns = array( + strtolower( 'Tables_in_' . $database_name ) => 'Tables_in_' . $database_name, + ); + if ( $is_full ) { + $allowed_columns['table_type'] = 'Table_type'; + } + + $where = $this->get_mysql_show_exact_where_filter( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $position += 4; + } + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { return null; } @@ -4230,6 +4262,7 @@ private function get_show_tables_query( string $query ): ?array { 'schema' => $schema_name, 'database' => $database_name, 'like' => $like, + 'where' => $where, ); } @@ -4681,6 +4714,36 @@ private function get_show_static_result_filter( return null; } + /** + * Parse a safe exact WHERE filter for dynamic SHOW result sets. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @return array{column: string, value: string}|null Parsed filter, or null when unsupported. + */ + private function get_mysql_show_exact_where_filter( array $tokens, int $position, array $allowed_columns ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 2 ]->id + || ! $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + || ! $this->is_at_mysql_query_end( $tokens, $position + 4 ) + ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( $tokens[ $position + 1 ], $allowed_columns ); + if ( null === $column ) { + return null; + } + + return array( + 'column' => $column, + 'value' => $tokens[ $position + 3 ]->get_value(), + ); + } + /** * Get the MySQL SHOW output column name represented by a token. * @@ -4714,8 +4777,16 @@ private function is_mysql_show_output_column_keyword_token( WP_MySQL_Token $toke WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, WP_MySQL_Lexer::CHARSET_SYMBOL, WP_MySQL_Lexer::COLLATION_SYMBOL, + WP_MySQL_Lexer::COLUMN_NAME_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, WP_MySQL_Lexer::DATABASE_SYMBOL, WP_MySQL_Lexer::DEFAULT_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::PRIVILEGES_SYMBOL, + WP_MySQL_Lexer::TABLE_SYMBOL, + WP_MySQL_Lexer::TYPE_SYMBOL, + WP_MySQL_Lexer::VISIBLE_SYMBOL, ), true ); @@ -4736,7 +4807,7 @@ private function is_mysql_quoted_text_token( WP_MySQL_Token $token ): bool { * Parse a supported MySQL SHOW COLUMNS/FIELDS statement. * * @param string $query MySQL query. - * @return array{schema: string, table: string, full: bool, like: string|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS/FIELDS statement. + * @return array{schema: string, table: string, full: bool, like: string|null, where: array{column: string, value: string}|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS/FIELDS statement. */ private function get_show_columns_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -4821,6 +4892,34 @@ private function get_show_columns_query( string $query ): ?array { $position += 2; } + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + if ( null !== $like ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $allowed_columns = array( + 'field' => 'Field', + 'type' => 'Type', + 'null' => 'Null', + 'key' => 'Key', + 'default' => 'Default', + 'extra' => 'Extra', + ); + if ( $is_full ) { + $allowed_columns['collation'] = 'Collation'; + $allowed_columns['privileges'] = 'Privileges'; + $allowed_columns['comment'] = 'Comment'; + } + + $where = $this->get_mysql_show_exact_where_filter( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $position += 4; + } + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); } @@ -4830,6 +4929,7 @@ private function get_show_columns_query( string $query ): ?array { 'table' => $table_name, 'full' => $is_full, 'like' => $like, + 'where' => $where, ); } @@ -4874,7 +4974,7 @@ private function get_show_columns_table_reference( array $tokens, int $position * family so unsupported forms fail before raw backend execution. * * @param string $query MySQL query. - * @return array{schema: string, table: string, key_name: string|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. + * @return array{schema: string, table: string, where: array{column: string, value: string}|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. */ private function get_show_index_query( string $query ): ?array { $tokens = $this->get_mysql_tokens( $query ); @@ -4937,23 +5037,33 @@ private function get_show_index_query( string $query ): ?array { $schema_name = $this->get_mysql_writable_table_backend_schema( $table_reference, 'SHOW INDEX' ); $table_name = $table_reference['table']; - $key_name = null; + $where = null; if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { - $where_column = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); - if ( - null === $where_column - || 'key_name' !== strtolower( $where_column ) - || ! isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) - || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 2 ]->id - || ( - WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 3 ]->id - && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 3 ]->id + $where = $this->get_mysql_show_exact_where_filter( + $tokens, + $position, + array( + 'table' => 'Table', + 'non_unique' => 'Non_unique', + 'key_name' => 'Key_name', + 'seq_in_index' => 'Seq_in_index', + 'column_name' => 'Column_name', + 'collation' => 'Collation', + 'cardinality' => 'Cardinality', + 'sub_part' => 'Sub_part', + 'packed' => 'Packed', + 'null' => 'Null', + 'index_type' => 'Index_type', + 'comment' => 'Comment', + 'index_comment' => 'Index_comment', + 'visible' => 'Visible', + 'expression' => 'Expression', ) - ) { + ); + if ( null === $where ) { throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); } - $key_name = $tokens[ $position + 3 ]->get_value(); $position += 4; } @@ -4962,9 +5072,9 @@ private function get_show_index_query( string $query ): ?array { } return array( - 'schema' => $schema_name, - 'table' => $table_name, - 'key_name' => $key_name, + 'schema' => $schema_name, + 'table' => $table_name, + 'where' => $where, ); } @@ -5393,15 +5503,16 @@ private function sqlite_database_schema_exists( string $schema_name ): bool { /** * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. * + * @param string $schema_name Schema name. * @param string $table_name Table name. * @param int $fetch_mode PDO fetch mode. * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed DESCRIBE result rows. */ - private function execute_describe_query( string $table_name, $fetch_mode, ...$fetch_mode_args ) { + private function execute_describe_query( string $schema_name, string $table_name, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); - $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); $cache_key = $this->get_mysql_introspection_result_cache_key( 'describe', $fetch_mode, @@ -5437,18 +5548,19 @@ private function execute_describe_query( string $table_name, $fetch_mode, ...$fe * @param string $table_name Table name. * @param bool $is_full Whether this is SHOW FULL COLUMNS. * @param string|null $like Optional MySQL LIKE pattern. + * @param array|null $where_filter Optional exact MySQL WHERE filter. * @param int $fetch_mode PDO fetch mode. * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed SHOW COLUMNS result rows. */ - private function execute_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + private function execute_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); $cache_key = $this->get_mysql_introspection_result_cache_key( 'show_columns', $fetch_mode, - array( $resolved_schema, $table_name, $is_full, $like, $fetch_mode, $fetch_mode_args ) + array( $resolved_schema, $table_name, $is_full, $like, $where_filter, $fetch_mode, $fetch_mode_args ) ); if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { return $this->last_result; @@ -5465,6 +5577,11 @@ private function execute_show_columns_query( string $schema_name, string $table_ $params[] = $like; } + if ( null !== $where_filter ) { + $sql .= sprintf( ' AND %s = ?', $this->get_show_columns_filter_column_expression( $where_filter['column'] ) ); + $params[] = $where_filter['value']; + } + $sql .= ' ORDER BY ordinal_position'; @@ -5482,6 +5599,37 @@ private function execute_show_columns_query( string $schema_name, string $table_ return $this->last_result; } + /** + * Get the SQL expression that backs a MySQL SHOW COLUMNS output column. + * + * @param string $column MySQL output column name. + * @return string SQL expression. + */ + private function get_show_columns_filter_column_expression( string $column ): string { + switch ( $column ) { + case 'Field': + return 'field_name'; + case 'Type': + return 'column_type'; + case 'Collation': + return 'collation_name'; + case 'Null': + return 'is_nullable'; + case 'Key': + return 'column_key'; + case 'Default': + return 'column_default'; + case 'Extra': + return 'column_extra'; + case 'Privileges': + return "'select,insert,update,references'"; + case 'Comment': + return "''"; + } + + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + /** * Execute a supported MySQL USE statement in session state. * @@ -5511,11 +5659,12 @@ private function execute_mysql_use_statement( string $database_name ): int { * @param string $schema_name Backend schema name. * @param string $database_name MySQL-facing database name. * @param string|null $like Optional MySQL LIKE pattern. + * @param array|null $where_filter Optional exact MySQL WHERE filter. * @param int $fetch_mode PDO fetch mode. * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed SHOW TABLES result rows. */ - private function execute_show_tables_query( bool $is_full, string $schema_name, string $database_name, ?string $like, $fetch_mode, ...$fetch_mode_args ) { + private function execute_show_tables_query( bool $is_full, string $schema_name, string $database_name, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { $table_column = $this->connection->quote_identifier( 'Tables_in_' . $database_name ); $sql = sprintf( 'SELECT table_name AS %s%s @@ -5536,6 +5685,11 @@ private function execute_show_tables_query( bool $is_full, string $schema_name, $params[] = $like; } + if ( null !== $where_filter ) { + $sql .= sprintf( ' AND %s = ?', $this->get_show_tables_filter_column_expression( $where_filter['column'], $table_column ) ); + $params[] = $where_filter['value']; + } + $sql .= ' ORDER BY table_name'; @@ -5551,6 +5705,25 @@ private function execute_show_tables_query( bool $is_full, string $schema_name, return $this->last_result; } + /** + * Get the SQL expression that backs a MySQL SHOW TABLES output column. + * + * @param string $column MySQL output column name. + * @param string $table_column Quoted Tables_in_* output column. + * @return string SQL expression. + */ + private function get_show_tables_filter_column_expression( string $column, string $table_column ): string { + if ( $table_column === $this->connection->quote_identifier( $column ) ) { + return 'table_name'; + } + + if ( 'Table_type' === $column ) { + return "CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'BASE TABLE' END"; + } + + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + /** * Execute a MySQL SHOW TABLE STATUS statement through PostgreSQL catalogs. * @@ -6943,19 +7116,19 @@ private function get_default_mysql_collation_for_charset( string $charset ): str * Execute a MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement through PostgreSQL catalogs. * * @param string $table_name Table name. - * @param string|null $key_name Optional MySQL Key_name filter. + * @param array|null $where_filter Optional exact MySQL WHERE filter. * @param int $fetch_mode PDO fetch mode. * @param array ...$fetch_mode_args Additional fetch mode arguments. * @return mixed SHOW INDEX result rows. */ - private function execute_show_index_query( string $schema_name, string $table_name, ?string $key_name, $fetch_mode, ...$fetch_mode_args ) { + private function execute_show_index_query( string $schema_name, string $table_name, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { $this->ensure_mysql_schema_metadata_tables(); $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); $cache_key = $this->get_mysql_introspection_result_cache_key( 'show_index', $fetch_mode, - array( $resolved_schema, $table_name, $key_name, $fetch_mode, $fetch_mode_args ) + array( $resolved_schema, $table_name, $where_filter, $fetch_mode, $fetch_mode_args ) ); if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { return $this->last_result; @@ -6967,10 +7140,13 @@ private function execute_show_index_query( string $schema_name, string $table_na $table_name, ); - if ( null !== $key_name ) { - $sql .= ' -WHERE "Key_name" = ?'; - $params[] = $key_name; + if ( null !== $where_filter ) { + $sql .= sprintf( + ' +WHERE %s = ?', + $this->connection->quote_identifier( $where_filter['column'] ) + ); + $params[] = $where_filter['value']; } $sql .= ' diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php index e382c36c4..7a5a90336 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php @@ -47,8 +47,18 @@ public function query( string $sql, array $params = array() ): PDOStatement { $fixture_params = array( $params[0] ?? '', $params[1] ?? '' ); if ( isset( $params[2] ) ) { - $fixture_sql .= ' + $filter_column = $this->get_show_index_fixture_filter_column( $sql ); + if ( null !== $filter_column ) { + $fixture_sql .= sprintf( + ' + AND %s = ?', + $filter_column + ); + } else { + $fixture_sql .= ' AND key_name = ?'; + } + $fixture_params[] = $params[2]; } @@ -58,6 +68,38 @@ public function query( string $sql, array $params = array() ): PDOStatement { return parent::query( $fixture_sql, $fixture_params ); } + /** + * Get the fixture column backing the SHOW INDEX filter in the driver query. + * + * @param string $sql Driver SQL query. + * @return string|null Fixture column name, or null for the legacy key_name filter. + */ + private function get_show_index_fixture_filter_column( string $sql ): ?string { + if ( ! preg_match( '/WHERE\s+"([^"]+)"\s+=\s+\?/i', $sql, $matches ) ) { + return null; + } + + $columns = array( + 'Table' => 'table_name', + 'Non_unique' => 'non_unique', + 'Key_name' => 'key_name', + 'Seq_in_index' => 'seq_in_index', + 'Column_name' => 'column_name', + 'Collation' => 'collation', + 'Cardinality' => 'cardinality', + 'Sub_part' => 'sub_part', + 'Packed' => 'packed', + 'Null' => 'nullable', + 'Index_type' => 'index_type', + 'Comment' => 'comment', + 'Index_comment' => 'index_comment', + 'Visible' => 'visible', + 'Expression' => 'expression', + ); + + return $columns[ $matches[1] ] ?? null; + } + /** * Install SHOW INDEX fixture rows into the injected PDO. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index 2218915f8..fa1ad8d25 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -6458,6 +6458,59 @@ public function test_desc_existing_table_returns_mysql_shaped_column_metadata(): $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); } + /** + * Tests DESCRIBE/DESC accepts current database-qualified table references. + */ + public function test_describe_accepts_current_database_qualification_forms(): void { + $cases = array( + 'DESCRIBE wptests.wptests_options', + 'DESC public.wptests_options', + 'DESC `wptests`.`wptests_options`', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESCRIBE', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESC', $queries[0]['sql'], $query ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'], $query ); + } + } + + /** + * Tests unsupported DESCRIBE/DESC qualifiers fail before backend execution. + */ + public function test_describe_unsupported_qualification_does_not_reach_backend(): void { + $queries = array( + 'DESC other_db.wptests_options' => 'Unsupported DESCRIBE statement.', + 'DESC information_schema.wptests_options' => 'Unsupported information_schema query.', + 'DESC wptests.wptests_options.extra' => 'Unsupported DESCRIBE statement.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DESCRIBE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests SHOW FULL COLUMNS returns MySQL-shaped PostgreSQL catalog rows. */ @@ -6909,34 +6962,93 @@ public function test_modify_column_updates_backend_and_metadata_definitions(): v } /** - * Tests unsupported SHOW COLUMNS clauses do not fall through to the backend. + * Tests SHOW COLUMNS WHERE exact filters catalog rows with bound parameters. */ - public function test_show_columns_where_clause_does_not_reach_backend(): void { + public function test_show_columns_where_exact_filters_catalog_rows(): void { $driver = $this->create_driver(); $this->install_information_schema_fixture( $driver ); - try { - $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); - $this->fail( 'Expected unsupported SHOW COLUMNS WHERE clause to throw.' ); - } catch ( InvalidArgumentException $e ) { - $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage() ); - $this->assertSame( array(), $driver->get_last_postgresql_queries() ); - } + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'varchar(191)', $result[0]->Type ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); } /** - * Tests unsupported SHOW FIELDS clauses do not fall through to the backend. + * Tests SHOW FIELDS WHERE exact filters use the SHOW COLUMNS parser. */ - public function test_show_fields_where_clause_does_not_reach_backend(): void { + public function test_show_fields_where_exact_filters_catalog_rows(): void { $driver = $this->create_driver(); $this->install_information_schema_fixture( $driver ); - try { - $driver->query( "SHOW FIELDS FROM wptests_options WHERE Field = 'option_name'" ); - $this->fail( 'Expected unsupported SHOW FIELDS WHERE clause to throw.' ); - } catch ( InvalidArgumentException $e ) { - $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage() ); - $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $result = $driver->query( "SHOW FIELDS FROM wptests_options WHERE Type = 'varchar(191)'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'varchar(191)', $result[0]->Type ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'column_type = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'varchar(191)' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FULL COLUMNS WHERE exact filters full catalog rows. + */ + public function test_show_full_columns_where_exact_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW FULL COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[0]->Collation ); + $this->assertSame( 9, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FULL COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); + } + + /** + * Tests unsupported SHOW COLUMNS/FIELDS WHERE forms do not reach the backend. + */ + public function test_show_columns_where_unsupported_forms_do_not_reach_backend(): void { + $queries = array( + "SHOW COLUMNS FROM wptests_options WHERE Field <> 'option_name'", + 'SHOW COLUMNS FROM wptests_options WHERE Field = option_name', + "SHOW COLUMNS FROM wptests_options WHERE Unknown = 'option_name'", + "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name' AND Type = 'varchar(191)'", + "SHOW FIELDS FROM wptests_options WHERE Privileges = 'select,insert,update,references'", + "SHOW COLUMNS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + "SHOW FIELDS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + "SHOW FULL COLUMNS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW COLUMNS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } } } @@ -7000,6 +7112,50 @@ public function test_show_tables_accepts_current_database_qualification_forms(): } } + /** + * Tests SHOW TABLES WHERE exact filters catalog rows with bound parameters. + */ + public function test_show_tables_where_exact_filters_catalog_rows(): void { + $cases = array( + "SHOW TABLES WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + "SHOW TABLES FROM wptests WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" => array( + 'Tables_in_wptests', + 2, + array( 'public', 'BASE TABLE' ), + ), + "SHOW FULL TABLES FROM wptests WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + ); + + foreach ( $cases as $query => $expected ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertCount( $expected[1], $tables, $query ); + $this->assertSame( $expected[0], $driver->get_last_column_meta()[0]['name'], $query ); + $this->assertSame( 'wptests_options', $tables[0]->{$expected[0]}, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'], $query ); + $this->assertSame( $expected[2], $queries[0]['params'], $query ); + } + } + /** * Tests unsupported SHOW TABLES database qualifiers fail before backend execution. */ @@ -7015,6 +7171,34 @@ public function test_show_tables_unsupported_database_qualification_does_not_rea } } + /** + * Tests unsupported SHOW TABLES WHERE forms do not reach the backend. + */ + public function test_show_tables_where_unsupported_forms_do_not_reach_backend(): void { + $queries = array( + "SHOW TABLES WHERE Table_type = 'BASE TABLE'", + "SHOW TABLES WHERE Tables_in_wptests LIKE 'wptests_%'", + 'SHOW TABLES WHERE Tables_in_wptests = wptests_options', + "SHOW TABLES WHERE Unknown = 'wptests_options'", + "SHOW TABLES WHERE Tables_in_wptests = 'wptests_options' AND Table_type = 'BASE TABLE'", + "SHOW TABLES LIKE 'wptests_%' WHERE Tables_in_wptests = 'wptests_options'", + "SHOW FULL TABLES LIKE 'wptests_%' WHERE Table_type = 'BASE TABLE'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests SHOW TABLES hides internal PostgreSQL metadata tables. */ @@ -8172,6 +8356,46 @@ public function test_show_keys_where_key_name_filters_catalog_rows(): void { $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); } + /** + * Tests SHOW INDEX WHERE exact filters support additional output columns. + */ + public function test_show_index_where_exact_filters_additional_output_columns(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_options WHERE Column_name = 'option_name'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'option_name', $indexes[0]->Key_name ); + $this->assertSame( 'option_name', $indexes[0]->Column_name ); + $this->assertSame( '0', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Column_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW KEYS WHERE exact filters support additional output columns. + */ + public function test_show_keys_where_exact_filters_additional_output_columns(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW KEYS FROM wptests_options WHERE Non_unique = '0'" ); + + $this->assertCount( 2, $indexes ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( 'option_name', $indexes[1]->Key_name ); + $this->assertSame( array( '0', '0' ), array( $indexes[0]->Non_unique, $indexes[1]->Non_unique ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Non_unique" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', '0' ), $queries[0]['params'] ); + } + /** * Tests SHOW INDEX-family statements accept current database qualification forms. */ From 9d3cfe8ebcffc5c5e1ea5f74fd3a063a4cae3d15 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 20:19:00 +0000 Subject: [PATCH 129/142] Clean generated wp-setup backup files Remove sed backup files created while patching generated WordPress test files so local backend probes do not see stale .bak artifacts. --- wp-setup.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wp-setup.sh b/wp-setup.sh index cc8e28bc8..1d25c4a33 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -246,6 +246,7 @@ if [ "$WP_TEST_DB_BACKEND" != "mysql" ]; then 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 @@ -255,10 +256,12 @@ if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_PostgreSQL_DB. echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_PostgreSQL_DB..." sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#require_once ABSPATH . 'wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php';\nclass WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak echo "Rewriting WordPress local-env install script for PostgreSQL..." node - "$WP_DIR/tools/local-env/scripts/install.js" << 'NODE' From c915b32a7ce92f2110e5f899c5990f738c19ec09 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 20:30:21 +0000 Subject: [PATCH 130/142] Support PostgreSQL ALTER DROP INDEX --- .../postgresql/class-wp-postgresql-driver.php | 50 ++++++- .../tests/WP_PostgreSQL_Driver_Tests.php | 124 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) 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 index 4c1e88c33..07ebbb91f 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -551,6 +551,10 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( null !== $alter_query ) { $result = $this->execute_postgresql_statements( $alter_query['statements'] ); $this->apply_mysql_dbdelta_alter_metadata( $alter_query['metadata'] ); + if ( 'drop_index' === ( $alter_query['metadata']['operation'] ?? '' ) ) { + $this->last_result = 0; + return $this->last_result; + } return $result; } @@ -2524,6 +2528,11 @@ private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { return; } + if ( 'drop_index' === $metadata['operation'] ) { + $this->apply_mysql_drop_index_metadata( $metadata ); + return; + } + if ( 'set_default' === $metadata['operation'] ) { $this->connection->query( sprintf( @@ -3371,6 +3380,29 @@ private function translate_mysql_dbdelta_alter_table_query( string $query ): ?ar ); } + if ( preg_match( '/^DROP\s+PRIMARY\s+KEY$/is', $clause ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + if ( preg_match( '/^DROP\s+(?:INDEX|KEY)\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))$/is', $clause, $drop_index_matches ) ) { + $index_name = '' !== ( $drop_index_matches['index_quoted'] ?? '' ) ? $drop_index_matches['index_quoted'] : $drop_index_matches['index']; + $drop_index_query = $this->get_mysql_drop_index_translation( + array( + 'schema' => null, + 'table' => $table_name, + ), + $index_name, + 'ALTER TABLE' + ); + + $drop_index_query['metadata']['operation'] = 'drop_index'; + return $drop_index_query; + } + + if ( preg_match( '/^DROP\s+(?:INDEX|KEY)\b/is', $clause ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + if ( preg_match( '/^ALTER\s+COLUMN\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+SET\s+DEFAULT\s+(?P.+)$/is', $clause, $default_matches ) ) { $column_name = '' !== ( $default_matches['column_quoted'] ?? '' ) ? $default_matches['column_quoted'] : $default_matches['column']; $default = $this->translate_mysql_default_fragment( $default_matches['default'] ); @@ -3770,7 +3802,23 @@ private function translate_mysql_drop_index_query( string $query ): ?array { throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); } - $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP INDEX' ); + return $this->get_mysql_drop_index_translation( $table_reference, $index_name, 'DROP INDEX' ); + } + + /** + * Build PostgreSQL DROP INDEX SQL and metadata cleanup target. + * + * @param array{schema: string|null, table: string} $table_reference MySQL table reference. + * @param string $index_name MySQL index name. + * @param string $statement_type Statement type for fail-closed error messages. + * @return array{statements: string[], metadata: array} Drop index translation. + */ + private function get_mysql_drop_index_translation( array $table_reference, string $index_name, string $statement_type ): array { + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, $statement_type ); $table_name = $table_reference['table']; return array( diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index fa1ad8d25..b78a50722 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1193,6 +1193,130 @@ public function test_standalone_drop_index_updates_postgresql_and_mysql_metadata $this->assertSame( array( 'PRIMARY' ), array_column( $indexes, 'key_name' ) ); } + /** + * Tests ALTER TABLE DROP INDEX removes PostgreSQL schema and MySQL metadata. + */ + public function test_alter_table_drop_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_index ( + id int NOT NULL, + option_name varchar(191) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_index ( + id int NOT NULL, + option_name varchar(191) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_index (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_drop_index DROP INDEX option_name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_index__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_index' ); + $this->assertSame( array( 'PRIMARY' ), array_column( $indexes, 'key_name' ) ); + } + + /** + * Tests ALTER TABLE DROP INDEX accepts backticked identifiers. + */ + public function test_alter_table_drop_index_accepts_backticked_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_backtick_index ( + option_name varchar(191) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_backtick_index ( + option_name varchar(191) NOT NULL + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_backtick_index (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE `wptests_alter_drop_backtick_index` DROP INDEX `option_name`' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_backtick_index__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_backtick_index' ) ); + } + + /** + * Tests ALTER TABLE DROP KEY removes PostgreSQL schema and MySQL metadata. + */ + public function test_alter_table_drop_key_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_key ( + option_name varchar(191) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_key ( + option_name varchar(191) NOT NULL + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_key (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_drop_key DROP KEY option_name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_key__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_key' ) ); + } + + /** + * Tests ALTER TABLE primary-key drop forms fail before backend execution. + */ + public function test_alter_table_drop_primary_key_forms_do_not_reach_backend(): void { + $queries = array( + 'ALTER TABLE wptests_alter_drop_primary DROP PRIMARY KEY', + 'ALTER TABLE wptests_alter_drop_primary DROP INDEX PRIMARY', + 'ALTER TABLE wptests_alter_drop_primary DROP KEY `PRIMARY`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE primary-key drop to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + /** * Tests main database-qualified standalone index statements target public table metadata. */ From 3a65a1d49c8448e7ed0b0edbd27801f24af5a6c3 Mon Sep 17 00:00:00 2001 From: adamziel Date: Sun, 14 Jun 2026 20:34:33 +0000 Subject: [PATCH 131/142] Add PostgreSQL adapter insert replace helper tests --- .../tests/WP_PostgreSQL_DB_Tests.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index b6bb83267..82fd3dc08 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -2417,6 +2417,162 @@ public function get_recorded_queries(): array { ); } + /** + * Tests the SQL generated by real wpdb insert helpers before the driver sees it. + */ + public function test_real_wpdb_insert_and_replace_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Insert_SQL_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } +} + +class WP_PostgreSQL_DB_Insert_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + private $insert_id = 0; + private $last_return_value = 0; + private $queries = array(); + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Insert_SQL_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 0 === stripos( $query, 'replace' ) ) { + $this->insert_id = 22; + $this->last_return_value = 2; + return 2; + } + + $this->insert_id = 11; + $this->last_return_value = 1; + return 1; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Insert_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$insert_return = $db->insert( + 'wptests_options', + array( + 'option_name' => 'blogdescription', + 'option_value' => 'Just another site', + ) +); +$insert_id_after_insert = $db->insert_id; + +$replace_return = $db->replace( + 'wptests_options', + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ) +); +$insert_id_after_replace = $db->insert_id; + +wp_postgresql_db_test_respond( + array( + 'loaded_wpdb' => class_exists( 'wpdb', false ), + 'insert_return' => $insert_return, + 'insert_id_after_insert' => $insert_id_after_insert, + 'replace_return' => $replace_return, + 'insert_id_after_replace' => $insert_id_after_replace, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['loaded_wpdb'] ); + $this->assertSame( 1, $result['insert_return'] ); + $this->assertSame( 11, $result['insert_id_after_insert'] ); + $this->assertSame( 2, $result['replace_return'] ); + $this->assertSame( 22, $result['insert_id_after_replace'] ); + $this->assertSame( + array( + "INSERT INTO `wptests_options` (`option_name`, `option_value`) VALUES ('blogdescription', 'Just another site')", + "REPLACE INTO `wptests_options` (`option_name`, `option_value`) VALUES ('siteurl', 'http://example.org')", + ), + $result['queries'] + ); + } + /** * Tests the SQL sent by real wpdb read helpers before the driver sees it. */ From f2632c8e9675f65833864a7bb0f29e2b5c724505 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 00:28:56 +0000 Subject: [PATCH 132/142] Support translated PostgreSQL CREATE INDEX forms Accept install-translated quoted standalone CREATE INDEX identifiers, including optional IF NOT EXISTS, while preserving already-prefixed PostgreSQL index names and storing normalized MySQL metadata names. Add focused package coverage for the quoted IF NOT EXISTS path and metadata normalization. --- .../postgresql/class-wp-postgresql-driver.php | 69 ++++++++++++++----- .../tests/WP_PostgreSQL_Driver_Tests.php | 42 +++++++++++ 2 files changed, 94 insertions(+), 17 deletions(-) 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 index 07ebbb91f..6337d050f 100644 --- a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -2950,7 +2950,19 @@ private function translate_mysql_create_index_query( string $query ): ?array { } ++$position; - $index_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + $if_not_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $if_not_exists = true; + $position += 3; + } + + $index_name_token = $tokens[ $position ] ?? null; + $index_name = $this->get_mysql_identifier_token_value( $index_name_token, true ); if ( null === $index_name ) { throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); } @@ -2965,7 +2977,8 @@ private function translate_mysql_create_index_query( string $query ): ?array { } ++$position; - $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $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.' ); } @@ -2995,15 +3008,30 @@ private function translate_mysql_create_index_query( string $query ): ?array { throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); } - $table_name = $table_reference['table']; - $postgresql_index = $this->connection->quote_identifier( $table_name . '__' . $index_name ); + $table_name = $table_reference['table']; + $metadata_index_name = $index_name; + $postgresql_index_name = $table_name . '__' . $index_name; + if ( + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $index_name_token->id + && $table_reference_is_quoted + && 0 === strpos( $index_name, $table_name . '__' ) + ) { + $metadata_index_name = substr( $index_name, strlen( $table_name ) + 2 ); + $postgresql_index_name = $index_name; + if ( '' === $metadata_index_name ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + } + + $postgresql_index = $this->connection->quote_identifier( $postgresql_index_name ); $postgresql_table = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); return array( 'statements' => array( sprintf( - 'CREATE %sINDEX %s ON %s (%s)', + 'CREATE %sINDEX %s%s ON %s (%s)', $is_unique ? 'UNIQUE ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', $postgresql_index, $postgresql_table, implode( ', ', $key_parts['sql'] ) @@ -3013,7 +3041,7 @@ private function translate_mysql_create_index_query( string $query ): ?array { 'schema' => $table_schema, 'table' => $table_name, 'index' => array( - 'name' => $index_name, + 'name' => $metadata_index_name, 'non_unique' => $is_unique ? '0' : '1', 'index_type' => 'BTREE', 'columns' => $key_parts['metadata'], @@ -3061,7 +3089,7 @@ private function parse_mysql_create_index_key_parts( array $tokens, int $start, $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 ); + $column_name = $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null, true ); if ( null === $column_name ) { return null; } @@ -3122,11 +3150,12 @@ private function parse_mysql_create_index_key_parts( array $tokens, int $start, * "name", in key parts. Keep this fallback local to index column parsing so * statement structure keywords are still handled explicitly by the parser. * - * @param WP_MySQL_Token|null $token MySQL token. + * @param WP_MySQL_Token|null $token MySQL token. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. * @return string|null Identifier value, or null when unsupported. */ - private function get_mysql_index_identifier_token_value( ?WP_MySQL_Token $token ): ?string { - $identifier = $this->get_mysql_identifier_token_value( $token ); + 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; } @@ -5189,12 +5218,13 @@ private function get_mysql_table_administration_query( string $query ): ?array { /** * Parse one table reference from a MySQL table administration statement. * - * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. - * @param int $position Current token position, updated on success. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. * @return array{schema: string|null, table: string}|null Parsed table reference, or null when unsupported. */ - private function get_mysql_table_administration_table_reference( array $tokens, int &$position ): ?array { - $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + private function get_mysql_table_administration_table_reference( array $tokens, int &$position, bool $allow_double_quoted = false ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, $allow_double_quoted ); if ( null === $first_identifier ) { return null; } @@ -5207,7 +5237,7 @@ private function get_mysql_table_administration_table_reference( array $tokens, ); } - $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null, $allow_double_quoted ); if ( null === $table_name ) { return null; } @@ -21334,10 +21364,11 @@ private function should_join_mysql_tokens_without_space( ?int $previous_token_id /** * Get a MySQL identifier token value. * - * @param WP_MySQL_Token|null $token MySQL token. + * @param WP_MySQL_Token|null $token MySQL token. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. * @return string|null Identifier value, or null when the token is unsupported. */ - private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token, bool $allow_double_quoted = false ): ?string { if ( null === $token ) { return null; } @@ -21346,6 +21377,10 @@ private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token ): ?st return $token->get_value(); } + if ( $allow_double_quoted && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + return null; } diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index b78a50722..f3bab2f38 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1089,6 +1089,48 @@ public function test_standalone_create_index_updates_postgresql_and_mysql_metada $this->assertSame( '', $indexes[1]['nullable'] ); } + /** + * Tests install-translated quoted CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ + public function test_standalone_create_index_accepts_install_translated_if_not_exists_quoted_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_options ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX IF NOT EXISTS "wptests_options__option_value" ON "wptests_options" ("option_value")' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX IF NOT EXISTS "wptests_options__option_value" ON "wptests_options" ("option_value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_options' ); + $this->assertSame( array( 'PRIMARY', 'option_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'option_value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + } + /** * Tests standalone CREATE INDEX keeps the index name unqualified for PostgreSQL. */ From c69e247af482177fd3f60ce3110220f6399103f0 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 00:29:00 +0000 Subject: [PATCH 133/142] Harden PostgreSQL WordPress test setup guards Detect stale generated backend/helper files and broken WordPress node dependency package health before running local test commands. Use the lightweight PostgreSQL setup path for stale repairs so local recovery avoids the full WordPress npm build when possible. --- .github/workflows/wp-tests-phpunit-run.js | 35 +++++++++++++++++++++++ composer.json | 5 +++- wp-setup.sh | 4 ++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 1d3b50da5..b06884adf 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -505,8 +505,19 @@ function validateGeneratedBackendFiles() { } const generatedDropin = path.join( repositoryRoot, 'wordpress', 'src', 'wp-content', 'db.php' ); + const generatedDropinBackup = path.join( repositoryRoot, 'wordpress', 'src', 'wp-content', 'db.php.bak' ); + const generatedTestUtils = path.join( repositoryRoot, 'wordpress', 'tests', 'phpunit', 'includes', 'utils.php' ); + const generatedTestUtilsBackup = path.join( repositoryRoot, 'wordpress', 'tests', 'phpunit', 'includes', 'utils.php.bak' ); const composeOverride = path.join( repositoryRoot, 'wordpress', 'docker-compose.override.yml' ); + assertFileAbsent( + generatedDropinBackup, + 'generated db.php sed backup' + ); + assertFileAbsent( + generatedTestUtilsBackup, + 'generated PHPUnit utils.php sed backup' + ); assertFileContains( generatedDropin, `: '${ backend }'`, @@ -538,10 +549,28 @@ function validateGeneratedBackendFiles() { `docker-compose.override.yml sets DATABASE_ENGINE=${ backend }` ); + if ( 'sqlite' === backend ) { + assertFileContains( + generatedTestUtils, + 'class WpdbExposedMethodsForTesting extends WP_SQLite_DB {', + 'generated PHPUnit helper extends WP_SQLite_DB' + ); + } + if ( 'postgresql' === backend ) { const installScript = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'scripts', 'install.js' ); const postgresqlPhpDockerfile = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'Dockerfile.postgresql-php' ); const postgresqlCliDockerfile = path.join( repositoryRoot, 'wordpress', 'tools', 'local-env', 'Dockerfile.postgresql-cli' ); + assertFileContains( + generatedTestUtils, + "require_once ABSPATH . 'wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php';", + 'generated PHPUnit helper loads the PostgreSQL wpdb adapter' + ); + assertFileContains( + generatedTestUtils, + 'class WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {', + 'generated PHPUnit helper extends WP_PostgreSQL_DB' + ); assertFileContains( composeOverride, 'postgres:', @@ -728,6 +757,12 @@ function assertFileExists( file, description ) { } } +function assertFileAbsent( file, description ) { + if ( fs.existsSync( file ) ) { + throw new Error( `Expected stale ${ description } to be absent: ${ file }.` ); + } +} + function readGeneratedFile( file ) { if ( ! fs.existsSync( file ) ) { throw new Error( `Expected generated file to exist: ${ file }.` ); diff --git a/composer.json b/composer.json index 2e50fdd11..4c95f6f89 100644 --- a/composer.json +++ b/composer.json @@ -55,15 +55,18 @@ ], "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 ) { 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 }` ] ] ); } 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 = \"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 }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", + "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": [ "@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" diff --git a/wp-setup.sh b/wp-setup.sh index 1d25c4a33..22ff977c4 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -521,7 +521,9 @@ install_wordpress_release_assets() { # 6. Install dependencies. if [ "$WP_TEST_DB_BACKEND" = "postgresql" ] && [ "$WP_TEST_SKIP_WORDPRESS_NPM" = "1" ]; then - echo "Skipping WordPress npm install and JavaScript build for PostgreSQL PHP tests..." + 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..." From 590772366e2fca37cdd229ac728dca9ec5464823 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 01:00:44 +0000 Subject: [PATCH 134/142] Add PostgreSQL follow-up coverage tests Add focused package coverage for two PostgreSQL follow-up cases. - Assert real wpdb query handling rejects empty-WHERE UPDATE statements before the driver executes SQL. - Cover schema-qualified install-translated quoted CREATE INDEX IF NOT EXISTS metadata handling. Keep this test-only follow-up separate from the already-pushed CREATE INDEX driver change and workflow/composer/wp-setup guard commits. --- .../tests/WP_PostgreSQL_DB_Tests.php | 95 +++++++++++++++++++ .../tests/WP_PostgreSQL_Driver_Tests.php | 42 ++++++++ 2 files changed, 137 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 82fd3dc08..bb6fc81ed 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -2417,6 +2417,101 @@ public function get_recorded_queries(): array { ); } + /** + * Tests real wpdb query rejects empty-WHERE UPDATE statements before driver execution. + */ + public function test_real_wpdb_query_rejects_empty_where_update_before_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Empty_Where_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + return 1; + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Empty_Where_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); +} +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$return = $db->query( "UPDATE `wptests_options` SET `option_value` = 'x' WHERE" ); + +wp_postgresql_db_test_respond( + array( + 'return' => $return, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), + 'rows_affected' => $db->rows_affected, + 'num_queries' => $db->num_queries, + ) +); +PHP + ); + + $this->assertFalse( $result['return'] ); + $this->assertSame( + 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.', + $result['last_error'] + ); + $this->assertSame( array(), $result['queries'] ); + $this->assertSame( 0, $result['rows_affected'] ); + $this->assertSame( 0, $result['num_queries'] ); + } + /** * Tests the SQL generated by real wpdb insert helpers before the driver sees it. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php index f3bab2f38..2014ba0fa 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -1131,6 +1131,48 @@ public function test_standalone_create_index_accepts_install_translated_if_not_e $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); } + /** + * Tests schema-qualified install-translated CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ + public function test_standalone_create_index_accepts_schema_qualified_install_translated_if_not_exists_quoted_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_schema_quoted_index ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_schema_quoted_index ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX IF NOT EXISTS "wptests_schema_quoted_index__option_value" ON "wptests"."wptests_schema_quoted_index" ("option_value")' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX IF NOT EXISTS "wptests_schema_quoted_index__option_value" ON "wptests_schema_quoted_index" ("option_value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_schema_quoted_index' ); + $this->assertSame( array( 'PRIMARY', 'option_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'option_value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + } + /** * Tests standalone CREATE INDEX keeps the index name unqualified for PostgreSQL. */ From cc85bd48518abcc196c848e814f2200111153d42 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 01:16:35 +0000 Subject: [PATCH 135/142] Test PostgreSQL SQL mode adapter filtering Add focused package coverage for WP_PostgreSQL_DB::set_sql_mode(). The test verifies empty input leaves driver state unchanged, incompatible modes from wpdb and the incompatible_sql_modes filter are removed, compatible modes are uppercased and forwarded to the PostgreSQL driver, and detached handles do not mutate driver state. --- .../tests/WP_PostgreSQL_DB_Tests.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index bb6fc81ed..1aff53ea1 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -113,6 +113,95 @@ class wpdb { ); } + /** + * Tests the wpdb adapter filters and forwards SQL mode state to the PostgreSQL driver. + */ + public function test_set_sql_mode_filters_incompatible_modes_and_updates_postgresql_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$GLOBALS['wp_postgresql_db_test_filter_calls'] = array(); + +function apply_filters( $hook_name, $value ) { + $GLOBALS['wp_postgresql_db_test_filter_calls'][] = array( + 'hook_name' => $hook_name, + 'value' => $value, + ); + + if ( 'incompatible_sql_modes' === $hook_name ) { + $value[] = 'ANSI_QUOTES'; + } + + return $value; +} + +class wpdb { + public $incompatible_modes = array( 'NO_ZERO_DATE' ); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$initial_mode = $driver->get_sql_mode(); +$db->set_sql_mode(); +$mode_after_empty_call = $driver->get_sql_mode(); +$filter_calls_after_empty = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$db->set_sql_mode( + array( + 'strict_trans_tables', + 'NO_ZERO_DATE', + 'ansi_quotes', + 'no_engine_substitution', + ) +); +$mode_after_filtered_call = $driver->get_sql_mode(); +$filter_calls_after_modes = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$driver_property->setValue( $db, null ); +$db->set_sql_mode( array( 'STRICT_ALL_TABLES' ) ); +$mode_after_detached_call = $driver->get_sql_mode(); + +wp_postgresql_db_test_respond( + array( + 'initial_mode' => $initial_mode, + 'mode_after_empty_call' => $mode_after_empty_call, + 'filter_calls_after_empty' => $filter_calls_after_empty, + 'mode_after_filtered_call' => $mode_after_filtered_call, + 'filter_calls_after_modes' => $filter_calls_after_modes, + 'mode_after_detached_call' => $mode_after_detached_call, + ) +); +PHP + ); + + $this->assertSame( 'NO_ENGINE_SUBSTITUTION', $result['initial_mode'] ); + $this->assertSame( 'NO_ENGINE_SUBSTITUTION', $result['mode_after_empty_call'] ); + $this->assertSame( array(), $result['filter_calls_after_empty'] ); + $this->assertSame( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', $result['mode_after_filtered_call'] ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( 'NO_ZERO_DATE' ), + ), + ), + $result['filter_calls_after_modes'] + ); + $this->assertSame( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', $result['mode_after_detached_call'] ); + } + /** * Tests the PostgreSQL adapter strips legacy charset text without MySQL. */ From 8add580e4e017a749387903c402ea480b91365b5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 01:41:53 +0000 Subject: [PATCH 136/142] Test PostgreSQL charset determination --- .../tests/WP_PostgreSQL_DB_Tests.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 1aff53ea1..0cd815703 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -113,6 +113,79 @@ class wpdb { ); } + /** + * Tests the wpdb adapter applies WordPress charset upgrade rules. + */ + public function test_determine_charset_applies_wordpress_utf8mb4_upgrade_rules(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb {} +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$results = array( + 'without_dbh' => $db->determine_charset( 'utf8', '' ), +); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $dbh_property->setAccessible( true ); +} +$dbh_property->setValue( $db, new stdClass() ); + +foreach ( + array( + 'utf8_empty' => array( 'utf8', '' ), + 'utf8_general_ci' => array( 'utf8', 'utf8_general_ci' ), + 'utf8_bin' => array( 'utf8', 'utf8_bin' ), + 'utf8mb4_unicode_ci' => array( 'utf8mb4', 'utf8mb4_unicode_ci' ), + 'latin1_swedish_ci' => array( 'latin1', 'latin1_swedish_ci' ), + ) as $name => $args +) { + $results[ $name ] = $db->determine_charset( $args[0], $args[1] ); +} + +wp_postgresql_db_test_respond( $results ); +PHP + ); + + $this->assertSame( + array( + 'without_dbh' => array( + 'charset' => 'utf8', + 'collate' => '', + ), + 'utf8_empty' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'utf8_general_ci' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'utf8_bin' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_bin', + ), + 'utf8mb4_unicode_ci' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'latin1_swedish_ci' => array( + 'charset' => 'latin1', + 'collate' => 'latin1_swedish_ci', + ), + ), + $result + ); + } + /** * Tests the wpdb adapter filters and forwards SQL mode state to the PostgreSQL driver. */ From 6cfe191bf1a1019c75a276b9c8b468c326860d9f Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 01:50:19 +0000 Subject: [PATCH 137/142] Test PostgreSQL print error recording --- .../tests/WP_PostgreSQL_DB_Tests.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 0cd815703..1982ae67b 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -275,6 +275,66 @@ class wpdb { $this->assertSame( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', $result['mode_after_detached_call'] ); } + /** + * Tests suppressed print_error() calls record explicit and stored errors. + */ + public function test_print_error_records_explicit_and_stored_errors_when_suppressed(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $last_error = 'stored backend failure'; + public $last_query = 'SELECT * FROM probe'; + public $suppress_errors = true; + public $show_errors = false; + + public function get_caller() { + return 'sentinel caller'; + } + } +} + +global $EZSQL_ERROR; +$EZSQL_ERROR = array(); + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$explicit_return = $db->print_error( 'explicit PostgreSQL error' ); + +$db->last_query = 'UPDATE probe SET x = 1'; +$stored_return = $db->print_error(); + +wp_postgresql_db_test_respond( + array( + 'explicit_return' => $explicit_return, + 'stored_return' => $stored_return, + 'errors' => $EZSQL_ERROR, + ) +); +PHP + ); + + $this->assertFalse( $result['explicit_return'] ); + $this->assertFalse( $result['stored_return'] ); + $this->assertSame( + array( + array( + 'query' => 'SELECT * FROM probe', + 'error_str' => 'explicit PostgreSQL error', + ), + array( + 'query' => 'UPDATE probe SET x = 1', + 'error_str' => 'stored backend failure', + ), + ), + $result['errors'] + ); + } + /** * Tests the PostgreSQL adapter strips legacy charset text without MySQL. */ From 6fd64ba124741d4aa140b42c4d050420d40a66cb Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 01:56:07 +0000 Subject: [PATCH 138/142] Test PostgreSQL flush state reset --- .../tests/WP_PostgreSQL_DB_Tests.php | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 1982ae67b..cba36010c 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -335,6 +335,69 @@ public function get_caller() { ); } + /** + * Tests flush() resets query state while preserving the connection handle. + */ + public function test_flush_resets_query_state_and_preserves_connection_handle(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $last_result = array( 'row' ); + public $col_info = array( 'column' ); + public $last_query = 'SELECT * FROM probe'; + public $rows_affected = 7; + public $num_rows = 3; + public $last_error = 'stored backend failure'; + public $result = 'driver-result'; + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$sentinel = new stdClass(); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $dbh_property->setAccessible( true ); +} +$dbh_property->setValue( $db, $sentinel ); + +$db->flush(); + +wp_postgresql_db_test_respond( + array( + 'last_result' => $db->last_result, + 'col_info' => $db->col_info, + 'last_query' => $db->last_query, + 'rows_affected' => $db->rows_affected, + 'num_rows' => $db->num_rows, + 'last_error' => $db->last_error, + 'result' => $db->result, + 'preserved_dbh' => $sentinel === $dbh_property->getValue( $db ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'last_result' => array(), + 'col_info' => null, + 'last_query' => null, + 'rows_affected' => 0, + 'num_rows' => 0, + 'last_error' => '', + 'result' => null, + 'preserved_dbh' => true, + ), + $result + ); + } + /** * Tests the PostgreSQL adapter strips legacy charset text without MySQL. */ From b160f9ac3f363aaf10d4d7b824f0ab6a279fa99e Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 02:16:52 +0000 Subject: [PATCH 139/142] Test PostgreSQL real escape behavior --- .../tests/WP_PostgreSQL_DB_Tests.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index cba36010c..8c4146538 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -398,6 +398,56 @@ class wpdb { ); } + /** + * Tests _real_escape() escapes scalar values and rejects non-scalar values. + */ + public function test_real_escape_escapes_scalars_and_rejects_non_scalars(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public function add_placeholder_escape( $query ) { + return 'placeholder:' . $query; + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +wp_postgresql_db_test_respond( + array( + 'apostrophe' => $db->_real_escape( "Bob's" ), + 'backslash' => $db->_real_escape( 'C:\\Temp' ), + 'nul_byte' => $db->_real_escape( "a\0b" ), + 'integer' => $db->_real_escape( 123 ), + 'boolean_true' => $db->_real_escape( true ), + 'null' => $db->_real_escape( null ), + 'array' => $db->_real_escape( array( 'x' ) ), + 'object' => $db->_real_escape( (object) array( 'x' => true ) ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'apostrophe' => 'placeholder:' . addslashes( "Bob's" ), + 'backslash' => 'placeholder:' . addslashes( 'C:\\Temp' ), + 'nul_byte' => 'placeholder:' . addslashes( "a\0b" ), + 'integer' => 'placeholder:123', + 'boolean_true' => 'placeholder:1', + 'null' => '', + 'array' => '', + 'object' => '', + ), + $result + ); + } + /** * Tests the PostgreSQL adapter strips legacy charset text without MySQL. */ From 672964c6673619d34d5af4b9ca4a484223fbc553 Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 02:40:27 +0000 Subject: [PATCH 140/142] Test PostgreSQL constructor behavior --- .../tests/WP_PostgreSQL_DB_Tests.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 8c4146538..aa232c9f9 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -6,6 +6,64 @@ * Unit tests for the PostgreSQL wpdb adapter. */ class WP_PostgreSQL_DB_Tests extends TestCase { + /** + * Tests the constructor registers itself globally and normalizes charset state. + */ + public function test_constructor_registers_global_wpdb_and_defaults_empty_charset(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public static $next_charset = ''; + + public $charset; + public $parent_args; + + public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { + $this->charset = self::$next_charset; + $this->parent_args = array( $dbuser, $dbpassword, $dbname, $dbhost ); + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +wpdb::$next_charset = ''; +$default_db = new WP_PostgreSQL_DB( 'pg_user', 'pg_pass', 'pg_db', 'pg_host' ); +$default_is_global = $GLOBALS['wpdb'] === $default_db; + +wpdb::$next_charset = 'latin1'; +$latin_db = new WP_PostgreSQL_DB( 'latin_user', 'latin_pass', 'latin_db', 'latin_host' ); +$latin_is_global = $GLOBALS['wpdb'] === $latin_db; + +wp_postgresql_db_test_respond( + array( + 'default_is_global' => $default_is_global, + 'default_args' => $default_db->parent_args, + 'default_charset' => $default_db->charset, + 'latin_is_global' => $latin_is_global, + 'latin_args' => $latin_db->parent_args, + 'latin_charset' => $latin_db->charset, + ) +); +PHP + ); + + $this->assertSame( + array( + 'default_is_global' => true, + 'default_args' => array( 'pg_user', 'pg_pass', 'pg_db', 'pg_host' ), + 'default_charset' => 'utf8mb4', + 'latin_is_global' => true, + 'latin_args' => array( 'latin_user', 'latin_pass', 'latin_db', 'latin_host' ), + 'latin_charset' => 'latin1', + ), + $result + ); + } + /** * Tests WordPress core's expected wpdb capability checks. */ From bd80088d4664848bc0226cbea70aae2f5beeb9fc Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 02:48:23 +0000 Subject: [PATCH 141/142] Test PostgreSQL server info fallback --- .../tests/WP_PostgreSQL_DB_Tests.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index aa232c9f9..79ee22691 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -2434,6 +2434,40 @@ public function getAttribute( $attribute ) { ); } + /** + * Tests db_server_info() reports a pending PostgreSQL connection without a driver. + */ + public function test_db_server_info_reports_pending_connection_without_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, null ); + +wp_postgresql_db_test_respond( + array( + 'server_info' => $db->db_server_info(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'server_info' => 'PostgreSQL backend pending connection', + ), + $result + ); + } + /** * Tests query state, metadata, and SAVEQUERIES mapping. */ From cb6380892773600b7fa821622b947b68bfbbe76c Mon Sep 17 00:00:00 2001 From: adamziel Date: Mon, 15 Jun 2026 02:57:35 +0000 Subject: [PATCH 142/142] Test PostgreSQL connection health checks --- .../tests/WP_PostgreSQL_DB_Tests.php | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php index 79ee22691..25b61101e 100644 --- a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -2434,6 +2434,150 @@ public function getAttribute( $attribute ) { ); } + /** + * Tests check_connection() probes an existing driver and reconnects after failure. + */ + public function test_check_connection_probes_existing_driver_and_reconnects_after_failure(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $ready = true; + public $last_error = ''; + public $dbname = ''; + public $bail_calls = array(); + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Check_Fake_Connection extends WP_PostgreSQL_Connection { + public $queries = array(); + public $should_throw = false; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( $sql, $params ); + + if ( $this->should_throw ) { + throw new RuntimeException( 'health probe failed' ); + } + + return ( new ReflectionClass( PDOStatement::class ) )->newInstanceWithoutConstructor(); + } +} + +class WP_PostgreSQL_DB_Check_Fake_Driver extends WP_PostgreSQL_Driver { + public $connection; + + public function __construct() {} + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } +} + +class WP_PostgreSQL_DB_Check_Testable extends WP_PostgreSQL_DB { + public $db_connect_calls = array(); + + public function __construct() {} + + public function db_connect( $allow_bail = true ) { + $this->db_connect_calls[] = $allow_bail; + return false; + } +} + +function wp_postgresql_db_check_set_driver( WP_PostgreSQL_DB $db, $driver ) { + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + $driver_property->setValue( $db, $driver ); +} + +function wp_postgresql_db_check_get_driver( WP_PostgreSQL_DB $db ) { + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + return $driver_property->getValue( $db ); +} + +$success_connection = new WP_PostgreSQL_DB_Check_Fake_Connection(); +$success_driver = new WP_PostgreSQL_DB_Check_Fake_Driver(); +$success_driver->connection = $success_connection; +$success_db = new WP_PostgreSQL_DB_Check_Testable(); +$success_db->ready = true; +$success_db->last_error = 'previous'; +$success_db->dbname = strtolower( 'WordPress' ); +wp_postgresql_db_check_set_driver( $success_db, $success_driver ); +$success_result = $success_db->check_connection( false ); +$success_driver_after_probe = wp_postgresql_db_check_get_driver( $success_db ); + +$failure_connection = new WP_PostgreSQL_DB_Check_Fake_Connection(); +$failure_connection->should_throw = true; +$failure_driver = new WP_PostgreSQL_DB_Check_Fake_Driver(); +$failure_driver->connection = $failure_connection; +$failure_db = new WP_PostgreSQL_DB_Check_Testable(); +$failure_db->ready = true; +$failure_db->last_error = 'previous'; +$failure_db->dbname = strtolower( 'WordPress' ); +wp_postgresql_db_check_set_driver( $failure_db, $failure_driver ); +$failure_result = $failure_db->check_connection( false ); +$failure_driver_after_probe = wp_postgresql_db_check_get_driver( $failure_db ); + +wp_postgresql_db_test_respond( + array( + 'success_result' => $success_result, + 'success_queries' => $success_connection->queries, + 'success_ready' => $success_db->ready, + 'success_last_error' => $success_db->last_error, + 'success_db_connect_calls' => $success_db->db_connect_calls, + 'success_driver_after_probe' => $success_driver_after_probe instanceof WP_PostgreSQL_Driver, + 'failure_queries' => $failure_connection->queries, + 'failure_result' => $failure_result, + 'failure_ready' => $failure_db->ready, + 'failure_last_error' => $failure_db->last_error, + 'failure_driver_after_probe' => null === $failure_driver_after_probe, + 'failure_db_connect_calls' => $failure_db->db_connect_calls, + 'failure_bail_calls' => $failure_db->bail_calls, + ) +); +PHP + ); + + $this->assertSame( + array( + 'success_result' => true, + 'success_queries' => array( + array( 'SELECT 1', array() ), + ), + 'success_ready' => true, + 'success_last_error' => 'previous', + 'success_db_connect_calls' => array(), + 'success_driver_after_probe' => true, + 'failure_queries' => array( + array( 'SELECT 1', array() ), + ), + 'failure_result' => false, + 'failure_ready' => false, + 'failure_last_error' => 'health probe failed', + 'failure_driver_after_probe' => true, + 'failure_db_connect_calls' => array( false ), + 'failure_bail_calls' => array(), + ), + $result + ); + } + /** * Tests db_server_info() reports a pending PostgreSQL connection without a driver. */