From e33a05dea6fb7910d32ecc9ab02b9d8bec3ad891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 15 Jul 2026 18:55:00 +0200 Subject: [PATCH 1/5] Store SQL modes as a bitmap Map supported SQL mode names to their native MySQL bit values and use the bitmap as the driver's internal representation. Serialize active modes by bit position so duplicates collapse and @@sql_mode follows MySQL's canonical order. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 156 ++++++++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 87 ++++++++++ .../WP_MySQL_On_SQLite_Translation_Tests.php | 6 +- 3 files changed, 232 insertions(+), 17 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index c8f20e27a..2a5d59c72 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -57,6 +57,69 @@ class WP_MySQL_On_SQLite extends PDO { */ const DRIVER_VERSION_VARIABLE_NAME = self::RESERVED_PREFIX . 'driver_version'; + /** + * MySQL SQL modes mapped to their bitmask values. + * + * The modes are ordered by their bit position, matching MySQL's canonical + * serialization order. + * + * See: + * https://github.com/mysql/mysql-server/blob/8.4/sql/system_variables.h + * https://github.com/mysql/mysql-server/blob/5.7/sql/sys_vars.cc + */ + private const SQL_MODES = array( + 'REAL_AS_FLOAT' => 1 << 0, + 'PIPES_AS_CONCAT' => 1 << 1, + 'ANSI_QUOTES' => 1 << 2, + 'IGNORE_SPACE' => 1 << 3, + 'NOT_USED' => 1 << 4, + 'ONLY_FULL_GROUP_BY' => 1 << 5, + 'NO_UNSIGNED_SUBTRACTION' => 1 << 6, + 'NO_DIR_IN_CREATE' => 1 << 7, + 'POSTGRESQL' => 1 << 8, + 'ORACLE' => 1 << 9, + 'MSSQL' => 1 << 10, + 'DB2' => 1 << 11, + 'MAXDB' => 1 << 12, + 'NO_KEY_OPTIONS' => 1 << 13, + 'NO_TABLE_OPTIONS' => 1 << 14, + 'NO_FIELD_OPTIONS' => 1 << 15, + 'MYSQL323' => 1 << 16, + 'MYSQL40' => 1 << 17, + 'ANSI' => 1 << 18, + 'NO_AUTO_VALUE_ON_ZERO' => 1 << 19, + 'NO_BACKSLASH_ESCAPES' => 1 << 20, + 'STRICT_TRANS_TABLES' => 1 << 21, + 'STRICT_ALL_TABLES' => 1 << 22, + 'NO_ZERO_IN_DATE' => 1 << 23, + 'NO_ZERO_DATE' => 1 << 24, + 'ALLOW_INVALID_DATES' => 1 << 25, + 'ERROR_FOR_DIVISION_BY_ZERO' => 1 << 26, + 'TRADITIONAL' => 1 << 27, + 'NO_AUTO_CREATE_USER' => 1 << 28, + 'HIGH_NOT_PRECEDENCE' => 1 << 29, + 'NO_ENGINE_SUBSTITUTION' => 1 << 30, + 'PAD_CHAR_TO_FULL_LENGTH' => 1 << 31, + + // Modes below require 64-bit PHP. + // TODO: Consider supporting these values on 32-bit PHP as well. + 'TIME_TRUNCATE_FRACTIONAL' => 1 << 32, + ); + + /** + * The default SQL modes shared by MySQL 5.7 and 8.0. + * + * MySQL 5.7 additionally enables NO_AUTO_CREATE_USER. + */ + private const DEFAULT_SQL_MODES = array( + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + 'NO_ZERO_DATE', + 'NO_ZERO_IN_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + ); + /** * A map of MySQL tokens to SQLite data types. * @@ -591,16 +654,9 @@ class WP_MySQL_On_SQLite extends PDO { * TODO: This may be represented using a temporary table in the future, * together with GLOBAL SQL mode (a non-temporary table). * - * @var string[] + * @var int */ - private $active_sql_modes = array( - 'ERROR_FOR_DIVISION_BY_ZERO', - 'NO_ENGINE_SUBSTITUTION', - 'NO_ZERO_DATE', - 'NO_ZERO_IN_DATE', - 'ONLY_FULL_GROUP_BY', - 'STRICT_TRANS_TABLES', - ); + private $active_sql_modes; /** * A name-to-value map of MySQL system variables for the current session. @@ -706,6 +762,7 @@ public function __construct( $this->mysql_version = $options['mysql_version'] ?? 80038; $this->main_db_name = $db_name; $this->db_name = $db_name; + $this->set_sql_modes( $this->get_default_sql_modes() ); // Check the database name. if ( '' === $this->db_name ) { @@ -1152,7 +1209,12 @@ public function get_saved_driver_version(): string { * @return bool True if the SQL mode is active, false otherwise. */ public function is_sql_mode_active( string $mode ): bool { - return in_array( strtoupper( $mode ), $this->active_sql_modes, true ); + $mode = strtoupper( $mode ); + if ( 'NOT_USED' === $mode && $this->mysql_version < 80000 ) { + return false; + } + return isset( self::SQL_MODES[ $mode ] ) + && ( $this->active_sql_modes & self::SQL_MODES[ $mode ] ) !== 0; } /** @@ -1196,7 +1258,7 @@ public function create_parser( string $query ): WP_MySQL_Parser { $lexer = new WP_MySQL_Lexer( $query, 80038, - $this->active_sql_modes + $this->get_active_sql_mode_names() ); $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() @@ -3524,8 +3586,14 @@ private function execute_set_system_variable_statement( if ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) { if ( 'sql_mode' === $name ) { - $modes = explode( ',', strtoupper( $value ) ); - $this->active_sql_modes = $modes; + if ( null !== $value_node->get_first_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $sql_modes = $this->get_default_sql_modes(); + } elseif ( is_string( $value ) ) { + $sql_modes = explode( ',', $value ); + } else { + $sql_modes = $value; + } + $this->set_sql_modes( $sql_modes ); } else { $this->session_system_variables[ $name ] = $value; } @@ -3876,7 +3944,7 @@ private function translate( $node ): ?string { $name = strtolower( $original_name ); $type = $type_token ? $type_token->id : WP_MySQL_Lexer::SESSION_SYMBOL; if ( 'sql_mode' === $name ) { - $value = implode( ',', $this->active_sql_modes ); + $value = implode( ',', $this->get_active_sql_mode_names() ); } elseif ( 'version' === $name ) { $version = (string) $this->mysql_version; $value = sprintf( @@ -5682,6 +5750,66 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare return $fragment; } + /** + * Get the default SQL modes for the emulated MySQL version. + * + * @return string[] Default SQL mode names. + */ + private function get_default_sql_modes(): array { + $sql_modes = self::DEFAULT_SQL_MODES; + if ( $this->mysql_version < 80000 ) { + $sql_modes[] = 'NO_AUTO_CREATE_USER'; + } + + return $sql_modes; + } + + /** + * Set the active SQL modes from a name list or numeric bitmask. + * + * @param string[]|int $modes SQL mode names or a numeric bitmask. + */ + private function set_sql_modes( $modes ): void { + if ( is_int( $modes ) ) { + $this->active_sql_modes = $modes; + return; + } + + $sql_modes = 0; + foreach ( (array) $modes as $mode ) { + $mode = strtoupper( trim( $mode ) ); + $sql_modes |= self::SQL_MODES[ $mode ] ?? 0; + } + + $this->active_sql_modes = $sql_modes; + } + + /** + * Get the active SQL mode names in canonical bitmask order. + * + * @return string[] Active SQL mode names. + */ + private function get_active_sql_mode_names(): array { + $active_modes = array(); + foreach ( self::SQL_MODES as $mode => $value ) { + if ( ( $this->active_sql_modes & $value ) !== 0 ) { + if ( 'NOT_USED' === $mode && $this->mysql_version < 80000 ) { + /* + * MySQL 5.7 represents reserved bit 4 with a comma in its mode + * name table. Two empty components preserve that serialization + * when the final list is joined with commas. + */ + $active_modes[] = ''; + $active_modes[] = ''; + } else { + $active_modes[] = $mode; + } + } + } + + return $active_modes; + } + /** * Store column metadata for the last SQLite statement. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 62aefbb89..a9637ee27 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -2860,6 +2860,93 @@ public function testDefaultSqlModeDoesNotIncludeNoAutoValueOnZero() { $this->assertStringNotContainsString( 'NO_AUTO_VALUE_ON_ZERO', strtoupper( $results[0]->mode ) ); } + public function testSqlModesUseCanonicalBitmapOrder() { + $this->assertQuery( + "SET sql_mode = 'no_engine_substitution,only_full_group_by,strict_all_tables,only_full_group_by'" + ); + + $this->assertTrue( $this->engine->is_sql_mode_active( 'ONLY_FULL_GROUP_BY' ) ); + $this->assertTrue( $this->engine->is_sql_mode_active( 'strict_all_tables' ) ); + $this->assertTrue( $this->engine->is_sql_mode_active( 'NO_ENGINE_SUBSTITUTION' ) ); + $this->assertFalse( $this->engine->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( + 'ONLY_FULL_GROUP_BY,STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION', + $this->last_result[0]->mode + ); + } + + public function testSqlModesAcceptNumericBitmap() { + $this->assertQuery( 'SET sql_mode = 4294967299' ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( + 'REAL_AS_FLOAT,PIPES_AS_CONCAT,TIME_TRUNCATE_FRACTIONAL', + $this->last_result[0]->mode + ); + } + + public function testSqlModesPreserveNotUsedBit() { + $this->assertQuery( "SET sql_mode = 'NOT_USED'" ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'NOT_USED', $this->last_result[0]->mode ); + + $this->assertQuery( 'SET sql_mode = 16' ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'NOT_USED', $this->last_result[0]->mode ); + } + + public function testMySQL57PreservesUnnamedSqlModeBit() { + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( + 'pdo' => $this->sqlite, + 'mysql_version' => 50744, + ) + ); + + $this->assertQuery( 'SET sql_mode = 16' ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( ',', $this->last_result[0]->mode ); + $this->assertFalse( $this->engine->is_sql_mode_active( 'NOT_USED' ) ); + } + + public function testSqlModeDefaultRestoresDefaultBitmap() { + $this->assertQuery( "SET sql_mode = ''" ); + $this->assertQuery( 'SET sql_mode = DEFAULT' ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( + 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', + $this->last_result[0]->mode + ); + } + + public function testSqlModeDefaultUsesEmulatedMySQLVersion() { + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( + 'pdo' => $this->sqlite, + 'mysql_version' => 50744, + ) + ); + + $expected_modes = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'; + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( $expected_modes, $this->last_result[0]->mode ); + + $this->assertQuery( "SET sql_mode = ''" ); + $this->assertQuery( 'SET sql_mode = DEFAULT' ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( $expected_modes, $this->last_result[0]->mode ); + } + public function testAutoIncrementZeroAdvancesSequenceByDefault() { // Default SQL modes do not include NO_AUTO_VALUE_ON_ZERO. // Values like 0 and '0' should behave like NULL and advance the sequence. diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index 894715ad5..e9073fb22 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -1638,17 +1638,17 @@ public function testHexadecimalLiterals(): void { public function testSystemVariables(): void { $this->assertQuery( - "SELECT 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES' AS `@@sql_mode`", + "SELECT 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' AS `@@sql_mode`", 'SELECT @@sql_mode' ); $this->assertQuery( - "SELECT 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES' AS `@@SESSION.sql_mode`", + "SELECT 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' AS `@@SESSION.sql_mode`", 'SELECT @@SESSION.sql_mode' ); $this->assertQuery( - "SELECT 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES' AS `@@GLOBAL.sql_mode`", + "SELECT 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' AS `@@GLOBAL.sql_mode`", 'SELECT @@GLOBAL.sql_mode' ); } From 951b55d4bbfaf4cf22da8bbf83b0e576e0975ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 15 Jul 2026 18:56:37 +0200 Subject: [PATCH 2/5] Reject unsupported SQL modes Validate SQL mode names and numeric masks before changing the session state. Report invalid values with MySQL's SQLSTATE 42000 and error 1231, while retaining MySQL's handling for empty list components and incorrect value types. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 118 ++++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 159 ++++++++++++++++++ 2 files changed, 268 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 2a5d59c72..5bc7abb36 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -3580,7 +3580,7 @@ private function execute_set_system_variable_statement( * SET updatable_views_with_limit = false; SELECT @@updatable_views_with_limit; -> NO */ $lowercase_value = null === $value ? null : strtolower( $value ); - if ( 'on' === $lowercase_value || 'off' === $lowercase_value ) { + if ( 'sql_mode' !== $name && ( 'on' === $lowercase_value || 'off' === $lowercase_value ) ) { $value = 'on' === $lowercase_value ? 1 : 0; } @@ -3589,7 +3589,7 @@ private function execute_set_system_variable_statement( if ( null !== $value_node->get_first_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { $sql_modes = $this->get_default_sql_modes(); } elseif ( is_string( $value ) ) { - $sql_modes = explode( ',', $value ); + $sql_modes = explode( ',', rtrim( $value, ' ' ) ); } else { $sql_modes = $value; } @@ -5770,20 +5770,104 @@ private function get_default_sql_modes(): array { * @param string[]|int $modes SQL mode names or a numeric bitmask. */ private function set_sql_modes( $modes ): void { - if ( is_int( $modes ) ) { - $this->active_sql_modes = $modes; - return; + if ( null === $modes ) { + throw $this->new_invalid_sql_mode_exception( $modes ); } - $sql_modes = 0; - foreach ( (array) $modes as $mode ) { - $mode = strtoupper( trim( $mode ) ); - $sql_modes |= self::SQL_MODES[ $mode ] ?? 0; + $known_sql_modes_mask = array_sum( self::SQL_MODES ); + + // TIME_TRUNCATE_FRACTIONAL was introduced in MySQL 8.0.1. + if ( $this->mysql_version < 80001 ) { + $known_sql_modes_mask &= ~self::SQL_MODES['TIME_TRUNCATE_FRACTIONAL']; + } + + // Numeric SQL mode bitmap assignment (e.g., "SET sql_mode = 4" for ANSI_QUOTES). + if ( is_int( $modes ) ) { + // Ensure the bitmap contains only SQL mode bits known to the emulated MySQL version. + if ( $modes < 0 || ( $modes & ~$known_sql_modes_mask ) !== 0 ) { + throw $this->new_invalid_sql_mode_exception( $modes ); + } + + // Reject recognized but no longer supported SQL modes. + $unsupported_sql_modes = 0; + foreach ( self::SQL_MODES as $mode => $value ) { + if ( ( $modes & $value ) !== 0 && $this->is_sql_mode_removed( $mode ) ) { + $unsupported_sql_modes |= $value; + } + } + + if ( 0 !== $unsupported_sql_modes ) { + throw $this->new_driver_exception( + sprintf( + 'SQLSTATE[HY000]: General error: 3899 sql_mode=0x%08x is not supported.', + $unsupported_sql_modes + ), + 'HY000' + ); + } + + $sql_modes = $modes; + } elseif ( is_array( $modes ) ) { + // String SQL mode assignment (e.g., "SET sql_mode = 'ANSI_QUOTES,STRICT_TRANS_TABLES'"). + $sql_modes = 0; + foreach ( $modes as $mode ) { + if ( '' === $mode ) { + continue; + } + + $normalized_mode = strtoupper( $mode ); + $mode_value = self::SQL_MODES[ $normalized_mode ] ?? 0; + if ( + 0 === ( $mode_value & $known_sql_modes_mask ) + || ( 'NOT_USED' === $normalized_mode && $this->mysql_version < 80000 ) + || $this->is_sql_mode_removed( $normalized_mode ) + ) { + throw $this->new_invalid_sql_mode_exception( $mode ); + } + + $sql_modes |= $mode_value; + } + } else { + throw $this->new_driver_exception( + "SQLSTATE[42000]: Syntax error or access violation: 1232 Incorrect argument type to variable 'sql_mode'", + '42000' + ); } $this->active_sql_modes = $sql_modes; } + /** + * Check whether an SQL mode was removed from the emulated MySQL version. + * + * @param string $mode Normalized SQL mode name. + * @return bool Whether the SQL mode was removed. + */ + private function is_sql_mode_removed( string $mode ): bool { + /* + * MySQL still recognizes the legacy bits for modes removed in 8.0.11 + * so it can report them as unsupported, rather than unknown. + */ + return $this->mysql_version >= 80011 + && in_array( + $mode, + array( + 'POSTGRESQL', + 'ORACLE', + 'MSSQL', + 'DB2', + 'MAXDB', + 'NO_KEY_OPTIONS', + 'NO_TABLE_OPTIONS', + 'NO_FIELD_OPTIONS', + 'MYSQL323', + 'MYSQL40', + 'NO_AUTO_CREATE_USER', + ), + true + ); + } + /** * Get the active SQL mode names in canonical bitmask order. * @@ -7294,6 +7378,22 @@ private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_ ); } + /** + * Create a MySQL-compatible exception for an invalid SQL mode value. + * + * @param mixed $value The invalid SQL mode value. + * @return WP_SQLite_Driver_Exception + */ + private function new_invalid_sql_mode_exception( $value ): WP_SQLite_Driver_Exception { + return $this->new_driver_exception( + sprintf( + "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of '%s'", + null === $value ? 'NULL' : (string) $value + ), + '42000' + ); + } + /** * Create a new access denied exception for the information schema database. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index a9637ee27..1b774e259 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -2914,6 +2914,91 @@ public function testMySQL57PreservesUnnamedSqlModeBit() { $this->assertFalse( $this->engine->is_sql_mode_active( 'NOT_USED' ) ); } + public function testSqlModeValidationUsesEmulatedMySQLVersion() { + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( + 'pdo' => $this->sqlite, + 'mysql_version' => 50744, + ) + ); + + $this->assertQuery( "SET sql_mode = 'POSTGRESQL,NO_AUTO_CREATE_USER'" ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'POSTGRESQL,NO_AUTO_CREATE_USER', $this->last_result[0]->mode ); + + foreach ( + array( + array( "SET sql_mode = 'TIME_TRUNCATE_FRACTIONAL'", 'TIME_TRUNCATE_FRACTIONAL' ), + array( 'SET sql_mode = 4294967296', '4294967296' ), + ) as $invalid_sql_mode + ) { + $exception = null; + try { + $this->query( $invalid_sql_mode[0] ); + } catch ( WP_SQLite_Driver_Exception $e ) { + $exception = $e; + } + + $this->assertInstanceOf( WP_SQLite_Driver_Exception::class, $exception ); + $this->assertSame( + sprintf( + "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of '%s'", + $invalid_sql_mode[1] + ), + $exception->getMessage() + ); + $this->assertSame( '42000', $exception->getCode() ); + } + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'POSTGRESQL,NO_AUTO_CREATE_USER', $this->last_result[0]->mode ); + } + + public function testMySQL57RejectsNotUsedSqlModeName() { + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( + 'pdo' => $this->sqlite, + 'mysql_version' => 50744, + ) + ); + + $this->expectException( WP_SQLite_Driver_Exception::class ); + $this->expectExceptionCode( '42000' ); + $this->expectExceptionMessage( + "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of 'NOT_USED'" + ); + + $this->query( "SET sql_mode = 'NOT_USED'" ); + } + + public function testRemovedSqlModeBitmapThrowsMySQLError() { + $this->assertQuery( "SET sql_mode = 'ANSI_QUOTES'" ); + + $exception = null; + try { + // ANSI_QUOTES is supported, while POSTGRESQL and ORACLE are not. + $this->query( 'SET sql_mode = 772' ); + } catch ( WP_SQLite_Driver_Exception $e ) { + $exception = $e; + } + + $this->assertInstanceOf( WP_SQLite_Driver_Exception::class, $exception ); + $this->assertSame( + 'SQLSTATE[HY000]: General error: 3899 sql_mode=0x00000300 is not supported.', + $exception->getMessage() + ); + $this->assertSame( 'HY000', $exception->getCode() ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'ANSI_QUOTES', $this->last_result[0]->mode ); + } + public function testSqlModeDefaultRestoresDefaultBitmap() { $this->assertQuery( "SET sql_mode = ''" ); $this->assertQuery( 'SET sql_mode = DEFAULT' ); @@ -2947,6 +3032,80 @@ public function testSqlModeDefaultUsesEmulatedMySQLVersion() { $this->assertSame( $expected_modes, $this->last_result[0]->mode ); } + /** + * @dataProvider invalidSqlModeValues + */ + public function testInvalidSqlModeValueThrowsMySQLError( string $query, string $invalid_value ) { + $this->assertQuery( "SET sql_mode = 'ANSI_QUOTES'" ); + + $exception = null; + try { + $this->query( $query ); + } catch ( WP_SQLite_Driver_Exception $e ) { + $exception = $e; + } + + $this->assertInstanceOf( WP_SQLite_Driver_Exception::class, $exception ); + $this->assertSame( + sprintf( + "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of '%s'", + $invalid_value + ), + $exception->getMessage() + ); + $this->assertSame( '42000', $exception->getCode() ); + + // A rejected assignment must not change the active modes. + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'ANSI_QUOTES', $this->last_result[0]->mode ); + } + + public function invalidSqlModeValues(): array { + return array( + 'unknown mode' => array( "SET sql_mode = 'FOOBAR'", 'FOOBAR' ), + 'ON keyword' => array( 'SET sql_mode = ON', 'ON' ), + 'quoted OFF' => array( "SET sql_mode = 'OFF'", 'OFF' ), + 'quoted DEFAULT' => array( "SET sql_mode = 'DEFAULT'", 'DEFAULT' ), + 'mode removed in MySQL 8.0' => array( "SET sql_mode = 'POSTGRESQL'", 'POSTGRESQL' ), + 'mixed valid and invalid' => array( "SET sql_mode = 'ERROR_FOR_DIVISION_BY_ZERO,FOOBAR,IGNORE_SPACE'", 'FOOBAR' ), + 'invalid among empty modes' => array( "SET sql_mode = ',,,,FOOBAR,,,,,'", 'FOOBAR' ), + 'leading mode whitespace' => array( "SET sql_mode = 'ANSI_QUOTES, NO_ENGINE_SUBSTITUTION'", ' NO_ENGINE_SUBSTITUTION' ), + 'trailing mode whitespace' => array( "SET sql_mode = 'ANSI_QUOTES ,NO_ENGINE_SUBSTITUTION'", 'ANSI_QUOTES ' ), + 'whitespace-only mode' => array( "SET sql_mode = 'ANSI_QUOTES, ,NO_ENGINE_SUBSTITUTION'", ' ' ), + 'null' => array( 'SET sql_mode = NULL', 'NULL' ), + 'negative bitmap' => array( 'SET sql_mode = -1', '-1' ), + 'unsupported bitmap bit' => array( 'SET sql_mode = 8589934592', '8589934592' ), + ); + } + + public function testSqlModeAllowsEmptyListComponents() { + $this->assertQuery( "SET sql_mode = ',,,,ONLY_FULL_GROUP_BY,,,'" ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'ONLY_FULL_GROUP_BY', $this->last_result[0]->mode ); + } + + public function testSqlModeIgnoresTrailingSpaces() { + $this->assertQuery( "SET sql_mode = 'ONLY_FULL_GROUP_BY, '" ); + + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( 'ONLY_FULL_GROUP_BY', $this->last_result[0]->mode ); + + $this->assertQuery( "SET sql_mode = ' '" ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $this->assertSame( '', $this->last_result[0]->mode ); + } + + public function testSqlModeRejectsIncorrectValueType() { + $this->expectException( WP_SQLite_Driver_Exception::class ); + $this->expectExceptionCode( '42000' ); + $this->expectExceptionMessage( + "SQLSTATE[42000]: Syntax error or access violation: 1232 Incorrect argument type to variable 'sql_mode'" + ); + + $this->query( 'SET sql_mode = 0.5' ); + } + public function testAutoIncrementZeroAdvancesSequenceByDefault() { // Default SQL modes do not include NO_AUTO_VALUE_ON_ZERO. // Values like 0 and '0' should behave like NULL and advance the sequence. From 7d597a174c6e6b9ba8254c1ed39daab212e987fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 18 Jun 2026 21:52:22 +0200 Subject: [PATCH 3/5] Add support for the ANSI_QUOTES SQL mode When ANSI_QUOTES is active, MySQL treats a double-quoted sequence as a quoted identifier rather than a string literal. Emulate this in the lexer by emitting a backtick-quoted identifier token for double-quoted text when the mode is set, mirroring how NO_BACKSLASH_ESCAPES already alters tokenization. The driver already forwards its active SQL modes to the lexer, so no further wiring is needed. See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes --- .../src/mysql/class-wp-mysql-lexer.php | 14 ++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 33 +++++++++++++++++ .../tests/mysql/WP_MySQL_Lexer_Tests.php | 37 +++++++++++++++++++ .../mysql-parser/src/class-wp-mysql-lexer.php | 8 +++- .../tests/WP_MySQL_Lexer_Tests.php | 23 ++++++++++++ .../tests/WP_MySQL_Token_Tests.php | 7 ++++ .../src/lexer_constants.rs | 5 +++ packages/php-ext-wp-mysql-parser/src/lib.rs | 3 ++ .../tools/generate-lexer-constants.php | 2 +- 9 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php index d6ee9970e..6b30de860 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php @@ -32,6 +32,7 @@ class WP_MySQL_Lexer { const SQL_MODE_PIPES_AS_CONCAT = 2; const SQL_MODE_IGNORE_SPACE = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES = 8; + const SQL_MODE_ANSI_QUOTES = 16; /** * Character masks for frequently used character classes. @@ -2209,6 +2210,8 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_IGNORE_SPACE; } elseif ( 'NO_BACKSLASH_ESCAPES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; + } elseif ( 'ANSI_QUOTES' === $sql_mode ) { + $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; } } } @@ -2951,7 +2954,16 @@ private function read_quoted_text(): ?int { if ( '`' === $quote ) { return self::BACK_TICK_QUOTED_ID; } elseif ( '"' === $quote ) { - return self::DOUBLE_QUOTED_TEXT; + /* + * With the ANSI_QUOTES SQL mode enabled, MySQL treats double quotes + * as identifier delimiters. Match this behavior by using the same token + * type as for backtick-quoted identifiers. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes + */ + return $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) + ? self::BACK_TICK_QUOTED_ID + : self::DOUBLE_QUOTED_TEXT; } else { return self::SINGLE_QUOTED_TEXT; } diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 1b774e259..7af7166a5 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -3170,6 +3170,39 @@ public function testNoAutoValueOnZeroSqlMode() { $this->assertEquals( 1, $results[0]->ID ); } + public function testDoubleQuotesAreStringLiteralsByDefault() { + $this->assertQuery( 'SELECT "hello" AS greeting;' ); + $results = $this->last_result; + $this->assertCount( 1, $results ); + $this->assertEquals( 'hello', $results[0]->greeting ); + } + + public function testAnsiQuotesTreatsDoubleQuotesAsIdentifiers() { + $this->assertQuery( "SET sql_mode = 'ANSI_QUOTES'" ); + + $this->assertQuery( + "INSERT INTO _options (option_name, option_value) VALUES ('alpha', 'one');" + ); + + $this->assertQuery( 'SELECT "option_name" AS "name" FROM _options WHERE "option_value" = \'one\';' ); + $results = $this->last_result; + $this->assertCount( 1, $results ); + $this->assertEquals( 'alpha', $results[0]->name ); + } + + public function testAnsiQuotesAllowsDoubleQuotedIdentifiersInDdl() { + $this->assertQuery( "SET sql_mode = 'ANSI_QUOTES'" ); + + // Identifiers may contain spaces and escape the double quote by doubling it. + $this->assertQuery( 'CREATE TABLE "my ""tbl""" ("my col" INTEGER);' ); + $this->assertQuery( 'INSERT INTO "my ""tbl""" ("my col") VALUES (42);' ); + $this->assertQuery( 'SELECT "my col" FROM "my ""tbl""";' ); + + $results = $this->last_result; + $this->assertCount( 1, $results ); + $this->assertEquals( 42, $results[0]->{'my col'} ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table ( diff --git a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php index 383b03f57..d7a543122 100644 --- a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php @@ -398,6 +398,43 @@ public function data_underscore_charset_after_dot(): array { ); } + /** + * @dataProvider data_ansi_quotes_tokenization + */ + public function test_ansi_quotes_changes_double_quote_tokenization( + string $sql, + array $sql_modes, + int $expected_token_id + ): void { + $lexer = new WP_MySQL_Lexer( $sql, 80038, $sql_modes ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( + WP_MySQL_Lexer::get_token_name( $expected_token_id ), + $lexer->get_token()->get_name(), + $sql + ); + } + + public function data_ansi_quotes_tokenization(): array { + return array( + 'double quote is a string by default' => array( '"foo"', array(), WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT ), + 'double quote is an identifier in ANSI_QUOTES' => array( '"foo"', array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + 'ANSI_QUOTES is case-insensitive' => array( '"foo"', array( 'ansi_quotes' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + 'ANSI_QUOTES with other modes' => array( '"foo"', array( 'STRICT_ALL_TABLES', 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + 'single quote stays a string in ANSI_QUOTES' => array( "'foo'", array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ), + 'backtick stays an identifier in ANSI_QUOTES' => array( '`foo`', array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + ); + } + + public function test_ansi_quotes_identifier_value_is_unescaped(): void { + $lexer = new WP_MySQL_Lexer( '"a""b"', 80038, array( 'ANSI_QUOTES' ) ); + $this->assertTrue( $lexer->next_token() ); + + $token = $lexer->get_token(); + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $token->id ); + $this->assertSame( 'a"b', $token->get_value() ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/mysql-parser/src/class-wp-mysql-lexer.php b/packages/mysql-parser/src/class-wp-mysql-lexer.php index 4d9e7b96b..a131bb755 100644 --- a/packages/mysql-parser/src/class-wp-mysql-lexer.php +++ b/packages/mysql-parser/src/class-wp-mysql-lexer.php @@ -114,6 +114,7 @@ class WP_MySQL_Lexer { const SQL_MODE_PIPES_AS_CONCAT = 2; const SQL_MODE_IGNORE_SPACE = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES = 8; + const SQL_MODE_ANSI_QUOTES = 16; /** * Character masks for frequently used character classes. @@ -345,6 +346,8 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_IGNORE_SPACE; } elseif ( 'NO_BACKSLASH_ESCAPES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; + } elseif ( 'ANSI_QUOTES' === $sql_mode ) { + $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; } } @@ -1252,7 +1255,10 @@ private function read_quoted_text(): ?int { $this->bytes_already_read = $at; - if ( '`' === $quote ) { + if ( + '`' === $quote + || ( '"' === $quote && $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) ) + ) { return self::BACK_TICK_QUOTED_ID; } elseif ( '"' === $quote ) { return self::DOUBLE_QUOTED_TEXT; diff --git a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php index c049a4da3..532441612 100644 --- a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php @@ -553,6 +553,29 @@ function ( $severity, $message, $file, $line ) { $this->assertNull( $lexer->get_token() ); } + /** + * @dataProvider data_ansi_quotes_tokenization + */ + public function test_ansi_quotes_changes_double_quote_tokenization( + string $sql, + array $sql_modes, + string $expected_token_name + ): void { + $tokens = ( new WP_MySQL_Lexer( $sql, 80400, $sql_modes ) )->remaining_tokens(); + $this->assertSame( $expected_token_name, $tokens[0]->get_name(), $sql ); + } + + public function data_ansi_quotes_tokenization(): array { + return array( + 'double quote is a string by default' => array( '"foo"', array(), 'SINGLE_QUOTED_TEXT' ), + 'double quote is an identifier in ANSI_QUOTES' => array( '"foo"', array( 'ANSI_QUOTES' ), 'BACK_TICK_QUOTED_ID' ), + 'ANSI_QUOTES is case-insensitive' => array( '"foo"', array( 'ansi_quotes' ), 'BACK_TICK_QUOTED_ID' ), + 'ANSI_QUOTES with other modes' => array( '"foo"', array( 'STRICT_ALL_TABLES', 'ANSI_QUOTES' ), 'BACK_TICK_QUOTED_ID' ), + 'single quote stays a string in ANSI_QUOTES' => array( "'foo'", array( 'ANSI_QUOTES' ), 'SINGLE_QUOTED_TEXT' ), + 'backtick stays an identifier in ANSI_QUOTES' => array( '`foo`', array( 'ANSI_QUOTES' ), 'BACK_TICK_QUOTED_ID' ), + ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php b/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php index b9b83d356..7e5cb07aa 100644 --- a/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php +++ b/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php @@ -38,6 +38,13 @@ public function test_get_value_unquotes_backtick_identifiers(): void { $this->assertSame( 'col name', self::first_token( 'SELECT `col name` FROM t', 'BACK_TICK_QUOTED_ID' )->get_value() ); } + public function test_get_value_unquotes_ansi_identifiers(): void { + $this->assertSame( + 'a"b', + self::first_token( 'SELECT "a""b"', 'BACK_TICK_QUOTED_ID', array( 'ANSI_QUOTES' ) )->get_value() + ); + } + public function test_get_value_does_not_unquote_unquoted_tokens(): void { // The SSL keyword's Bison number collides with one of the lexer's // internal quoted-text constants; value extraction must not be fooled diff --git a/packages/php-ext-wp-mysql-parser/src/lexer_constants.rs b/packages/php-ext-wp-mysql-parser/src/lexer_constants.rs index 5a3d3a40b..be052a2b1 100644 --- a/packages/php-ext-wp-mysql-parser/src/lexer_constants.rs +++ b/packages/php-ext-wp-mysql-parser/src/lexer_constants.rs @@ -1183,6 +1183,7 @@ pub const SCALAR_INT_CONSTANTS: &[(&str, i64)] = &[ ("SQL_MODE_PIPES_AS_CONCAT", 2i64), ("SQL_MODE_IGNORE_SPACE", 4i64), ("SQL_MODE_NO_BACKSLASH_ESCAPES", 8i64), + ("SQL_MODE_ANSI_QUOTES", 16i64), ("ACCESSIBLE_SYMBOL", 1i64), ("ACCOUNT_SYMBOL", 2i64), ("ACTION_SYMBOL", 3i64), @@ -2046,6 +2047,7 @@ pub const SQL_MODE_HIGH_NOT_PRECEDENCE: i64 = 1i64; pub const SQL_MODE_PIPES_AS_CONCAT: i64 = 2i64; pub const SQL_MODE_IGNORE_SPACE: i64 = 4i64; pub const SQL_MODE_NO_BACKSLASH_ESCAPES: i64 = 8i64; +pub const SQL_MODE_ANSI_QUOTES: i64 = 16i64; pub const WHITESPACE_MASK: &str = " \t\n\r\x0c"; pub const DIGIT_MASK: &str = "0123456789"; pub const HEX_DIGIT_MASK: &str = "0123456789abcdefABCDEF"; @@ -4041,6 +4043,9 @@ pub fn register_lexer_constants(mut builder: ClassBuilder) -> ClassBuilder { builder = builder .constant("SQL_MODE_NO_BACKSLASH_ESCAPES", 8i64, &[]) .unwrap(); + builder = builder + .constant("SQL_MODE_ANSI_QUOTES", 16i64, &[]) + .unwrap(); builder = builder .constant("WHITESPACE_MASK", " \t\n\r\x0c", &[]) .unwrap(); diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index 35f17fbd9..d0be38cba 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -24,6 +24,7 @@ const SQL_MODE_HIGH_NOT_PRECEDENCE: i64 = 1; const SQL_MODE_PIPES_AS_CONCAT: i64 = 2; const SQL_MODE_IGNORE_SPACE: i64 = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES: i64 = 8; +const SQL_MODE_ANSI_QUOTES: i64 = 16; const STACK_RED_ZONE: usize = 128 * 1024; const STACK_GROW_SIZE: usize = 8 * 1024 * 1024; @@ -137,6 +138,7 @@ fn sql_modes_mask(sql_modes: &[String]) -> i64 { "PIPES_AS_CONCAT" => mask |= SQL_MODE_PIPES_AS_CONCAT, "IGNORE_SPACE" => mask |= SQL_MODE_IGNORE_SPACE, "NO_BACKSLASH_ESCAPES" => mask |= SQL_MODE_NO_BACKSLASH_ESCAPES, + "ANSI_QUOTES" => mask |= SQL_MODE_ANSI_QUOTES, _ => {} } } @@ -813,6 +815,7 @@ impl WpMySqlNativeLexer { self.bytes_already_read = at + 1; Some(match quote { b'`' => lex::BACK_TICK_QUOTED_ID, + b'"' if self.is_sql_mode_active(SQL_MODE_ANSI_QUOTES) => lex::BACK_TICK_QUOTED_ID, b'"' => lex::DOUBLE_QUOTED_TEXT, _ => lex::SINGLE_QUOTED_TEXT, }) diff --git a/packages/php-ext-wp-mysql-parser/tools/generate-lexer-constants.php b/packages/php-ext-wp-mysql-parser/tools/generate-lexer-constants.php index 10a7c5c29..78bca3075 100644 --- a/packages/php-ext-wp-mysql-parser/tools/generate-lexer-constants.php +++ b/packages/php-ext-wp-mysql-parser/tools/generate-lexer-constants.php @@ -1,7 +1,7 @@ getConstants(); From d8fbcfa14298de425dc9af6963fa98116c89fdf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 18 Jun 2026 21:58:52 +0200 Subject: [PATCH 4/5] Expand the composite ANSI SQL mode MySQL's composite ANSI mode is shorthand for a set of component modes. Expand it when sql_mode is set and store the resulting list, so that "@@sql_mode" and individual mode checks reflect the components: REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, ONLY_FULL_GROUP_BY REAL_AS_FLOAT and ONLY_FULL_GROUP_BY are stored but not yet respected by the emulation. The driver is the source of truth for SQL modes, but the lexer also recognizes the composite ANSI mode directly so it stays correct when used standalone, applying the components that affect tokenization: PIPES_AS_CONCAT, IGNORE_SPACE, and ANSI_QUOTES. See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi --- .../src/mysql/class-wp-mysql-lexer.php | 11 ++++++ .../src/sqlite/class-wp-mysql-on-sqlite.php | 17 +++++++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 38 +++++++++++++++++++ .../tests/mysql/WP_MySQL_Lexer_Tests.php | 17 +++++++++ .../mysql-parser/src/class-wp-mysql-lexer.php | 11 ++++++ .../tests/WP_MySQL_Lexer_Tests.php | 17 +++++++++ packages/php-ext-wp-mysql-parser/src/lib.rs | 3 ++ 7 files changed, 114 insertions(+) diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php index 6b30de860..44b50c95b 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php @@ -2212,6 +2212,17 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; } elseif ( 'ANSI_QUOTES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; + } elseif ( 'ANSI' === $sql_mode ) { + /* + * Expand the composite ANSI mode into its lexer-relevant components. + * The ANSI mode also implies REAL_AS_FLOAT and ONLY_FULL_GROUP_BY, + * which do not affect the lexer. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi + */ + $this->sql_modes |= self::SQL_MODE_PIPES_AS_CONCAT + | self::SQL_MODE_IGNORE_SPACE + | self::SQL_MODE_ANSI_QUOTES; } } } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 5bc7abb36..2172bfbdb 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -5834,6 +5834,23 @@ private function set_sql_modes( $modes ): void { ); } + /* + * MySQL retains composite SQL modes while enabling their component modes. + * Store both so "@@sql_mode" and individual mode checks match MySQL, even + * though not all resulting modes are respected by the emulation yet. + * + * See: + * https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sql-mode-combo + * https://github.com/mysql/mysql-server/blob/8.4/sql/sys_vars.cc + */ + if ( ( $sql_modes & self::SQL_MODES['ANSI'] ) !== 0 ) { + $sql_modes |= self::SQL_MODES['REAL_AS_FLOAT'] + | self::SQL_MODES['PIPES_AS_CONCAT'] + | self::SQL_MODES['ANSI_QUOTES'] + | self::SQL_MODES['IGNORE_SPACE'] + | self::SQL_MODES['ONLY_FULL_GROUP_BY']; + } + $this->active_sql_modes = $sql_modes; } diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 7af7166a5..646e9f382 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -3203,6 +3203,44 @@ public function testAnsiQuotesAllowsDoubleQuotedIdentifiersInDdl() { $this->assertEquals( 42, $results[0]->{'my col'} ); } + public function testCompositeAnsiModeEnablesAnsiQuotes() { + $this->assertQuery( "SET sql_mode = 'ANSI'" ); + + $this->assertQuery( + "INSERT INTO _options (option_name, option_value) VALUES ('alpha', 'one');" + ); + + $this->assertQuery( 'SELECT "option_name" AS "name" FROM _options WHERE "option_value" = \'one\';' ); + $results = $this->last_result; + $this->assertCount( 1, $results ); + $this->assertEquals( 'alpha', $results[0]->name ); + } + + public function testCompositeAnsiModeExpandsToComponentModes() { + $this->assertQuery( "SET sql_mode = 'ANSI'" ); + + // The composite ANSI mode is retained alongside its component modes. + $this->assertTrue( $this->engine->is_sql_mode_active( 'ANSI' ) ); + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $results = $this->last_result; + $this->assertSame( + 'REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ONLY_FULL_GROUP_BY,ANSI', + $results[0]->mode + ); + } + + public function testCompositeAnsiModeExpandsAlongsideOtherModes() { + $this->assertQuery( "SET sql_mode = 'NO_ENGINE_SUBSTITUTION,STRICT_ALL_TABLES,ANSI'" ); + + // The expanded modes are returned in MySQL's canonical bitmask order. + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $results = $this->last_result; + $this->assertSame( + 'REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ONLY_FULL_GROUP_BY,ANSI,STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION', + $results[0]->mode + ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table ( diff --git a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php index d7a543122..6f9ffb993 100644 --- a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php @@ -435,6 +435,23 @@ public function test_ansi_quotes_identifier_value_is_unescaped(): void { $this->assertSame( 'a"b', $token->get_value() ); } + public function test_composite_ansi_mode_expands_to_lexer_component_modes(): void { + // ANSI_QUOTES: a double-quoted sequence is a quoted identifier. + $lexer = new WP_MySQL_Lexer( '"foo"', 80038, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $lexer->get_token()->id ); + + // PIPES_AS_CONCAT: "||" is the string concatenation operator. + $lexer = new WP_MySQL_Lexer( '||', 80038, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::CONCAT_PIPES_SYMBOL, $lexer->get_token()->id ); + + // IGNORE_SPACE: whitespace is permitted between a function name and "(". + $lexer = new WP_MySQL_Lexer( 'COUNT (1)', 80038, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::COUNT_SYMBOL, $lexer->get_token()->id ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/mysql-parser/src/class-wp-mysql-lexer.php b/packages/mysql-parser/src/class-wp-mysql-lexer.php index a131bb755..e71c1490c 100644 --- a/packages/mysql-parser/src/class-wp-mysql-lexer.php +++ b/packages/mysql-parser/src/class-wp-mysql-lexer.php @@ -348,6 +348,17 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; } elseif ( 'ANSI_QUOTES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; + } elseif ( 'ANSI' === $sql_mode ) { + /* + * Expand the composite ANSI mode into its lexer-relevant components. + * The ANSI mode also implies REAL_AS_FLOAT and ONLY_FULL_GROUP_BY, + * which do not affect the lexer. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi + */ + $this->sql_modes |= self::SQL_MODE_PIPES_AS_CONCAT + | self::SQL_MODE_IGNORE_SPACE + | self::SQL_MODE_ANSI_QUOTES; } } diff --git a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php index 532441612..94fb43d20 100644 --- a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php @@ -576,6 +576,23 @@ public function data_ansi_quotes_tokenization(): array { ); } + public function test_composite_ansi_mode_expands_to_lexer_component_modes(): void { + // ANSI_QUOTES: a double-quoted sequence is a quoted identifier. + $lexer = new WP_MySQL_Lexer( '"foo"', 80400, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $lexer->get_token()->id ); + + // PIPES_AS_CONCAT: "||" is the string concatenation operator. + $lexer = new WP_MySQL_Lexer( '||', 80400, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::CONCAT_PIPES_SYMBOL, $lexer->get_token()->id ); + + // IGNORE_SPACE: whitespace is permitted between a function name and "(". + $lexer = new WP_MySQL_Lexer( 'COUNT (1)', 80400, array( 'ANSI' ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( WP_MySQL_Lexer::KEYWORDS['COUNT'], $lexer->get_token()->id ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index d0be38cba..6a78163ed 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -139,6 +139,9 @@ fn sql_modes_mask(sql_modes: &[String]) -> i64 { "IGNORE_SPACE" => mask |= SQL_MODE_IGNORE_SPACE, "NO_BACKSLASH_ESCAPES" => mask |= SQL_MODE_NO_BACKSLASH_ESCAPES, "ANSI_QUOTES" => mask |= SQL_MODE_ANSI_QUOTES, + "ANSI" => { + mask |= SQL_MODE_PIPES_AS_CONCAT | SQL_MODE_IGNORE_SPACE | SQL_MODE_ANSI_QUOTES + } _ => {} } } From 647bb254b9f16fd2236c58abce2c00f26e816a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Thu, 18 Jun 2026 22:02:18 +0200 Subject: [PATCH 5/5] Treat backslashes literally in quoted identifiers MySQL does not process backslash escape sequences inside quoted identifiers; the bounding quote is escaped only by doubling it. The lexer was applying string-literal backslash escaping to backtick identifiers (and, with the new ANSI_QUOTES support, to double-quoted identifiers), both when scanning for the closing quote and when unquoting the value. As a result, an identifier like `a\nb` resolved to "ab", and `a\` (a trailing backslash) failed to tokenize because the backslash was treated as escaping the closing quote. Restrict backslash escaping to string literals so identifiers preserve backslashes verbatim. See: https://dev.mysql.com/doc/refman/8.4/en/identifiers.html --- .../src/mysql/class-wp-mysql-lexer.php | 47 ++++++++++--------- .../src/mysql/class-wp-mysql-token.php | 11 +++-- .../tests/mysql/WP_MySQL_Lexer_Tests.php | 43 +++++++++++++++++ .../mysql-parser/src/class-wp-mysql-lexer.php | 35 +++++++++----- .../mysql-parser/src/class-wp-mysql-token.php | 11 +++-- .../tests/WP_MySQL_Lexer_Tests.php | 39 +++++++++++++++ packages/php-ext-wp-mysql-parser/src/lib.rs | 12 +++-- 7 files changed, 152 insertions(+), 46 deletions(-) diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php index 44b50c95b..74f4da385 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php @@ -2905,15 +2905,30 @@ private function read_number(): ?int { * * Rules: * 1. Quotes can be escaped by doubling them ('', "", ``). - * 2. Backslashes escape the next character, unless NO_BACKSLASH_ESCAPES is set. + * 2. In string literals, backslashes escape the next character, + * unless the NO_BACKSLASH_ESCAPES SQL mode is set. + * 3. In identifiers, backslashes are always literal and never escape. */ private function read_quoted_text(): ?int { $quote = $this->sql[ $this->bytes_already_read ]; $this->bytes_already_read += 1; // Consume the quote. - $no_backslash_escapes = $this->is_sql_mode_active( - self::SQL_MODE_NO_BACKSLASH_ESCAPES - ); + /* + * Determine whether the quote opens an identifier or a string literal. + * An identifier is quoted with a backtick or a double quote when the + * ANSI_QUOTES SQL mode is active. Otherwise, it is a string literal. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes + */ + $is_identifier_quote = '`' === $quote + || ( '"' === $quote && $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) ); + + /* + * Backslash escapes apply only to string literals, and only when the + * NO_BACKSLASH_ESCAPES SQL mode is not set. + */ + $backslash_is_escape = ! $is_identifier_quote + && ! $this->is_sql_mode_active( self::SQL_MODE_NO_BACKSLASH_ESCAPES ); // We need to look for the closing quote in a loop, as it can be escaped, // in which case the escape sequence is consumed and the loop continues. @@ -2926,9 +2941,9 @@ private function read_quoted_text(): ?int { $at = $quote_at; /* - * By default, quotes can be escaped with a "\". - * When NO_BACKSLASH_ESCAPES SQL mode is active, the "\" treated as - * a regular character. + * In string literals, quotes can be escaped with a backslash. When + * NO_BACKSLASH_ESCAPES SQL mode is active, the backslash is treated + * as a regular character. Identifiers never use backslash escaping. * * The quote is escaped only when the number of preceding backslashes * is odd - "\" is an escape sequence, "\\" is an escaped backslash, @@ -2939,7 +2954,7 @@ private function read_quoted_text(): ?int { * sits at the very start of the input. The `?? null` covers * positive out-of-range indexes belt-and-suspenders. */ - if ( ! $no_backslash_escapes ) { + if ( $backslash_is_escape ) { $i = 0; while ( ( $at - $i - 1 ) >= 0 && '\\' === ( $this->sql[ $at - $i - 1 ] ?? null ) ) { $i += 1; @@ -2962,22 +2977,10 @@ private function read_quoted_text(): ?int { $this->bytes_already_read = $at; - if ( '`' === $quote ) { + if ( $is_identifier_quote ) { return self::BACK_TICK_QUOTED_ID; - } elseif ( '"' === $quote ) { - /* - * With the ANSI_QUOTES SQL mode enabled, MySQL treats double quotes - * as identifier delimiters. Match this behavior by using the same token - * type as for backtick-quoted identifiers. - * - * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes - */ - return $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) - ? self::BACK_TICK_QUOTED_ID - : self::DOUBLE_QUOTED_TEXT; - } else { - return self::SINGLE_QUOTED_TEXT; } + return '"' === $quote ? self::DOUBLE_QUOTED_TEXT : self::SINGLE_QUOTED_TEXT; } private function read_line_comment(): int { diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php index 0840bc2f2..1cbbb0ab7 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php @@ -72,11 +72,14 @@ public function get_value(): string { $value = substr( $value, 1, -1 ); /* - * When the NO_BACKSLASH_ESCAPES SQL mode is enabled, we only need to - * handle escaped bounding quotes, as the other characters preserve - * their literal values. + * For quoted identifiers and when the NO_BACKSLASH_ESCAPES SQL mode + * is active, we only need to handle escaped bounding quotes, as all + * other characters preserve their literal values. */ - if ( $this->sql_mode_no_backslash_escapes_enabled ) { + if ( + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id + || $this->sql_mode_no_backslash_escapes_enabled + ) { return str_replace( $quote . $quote, $quote, $value ); } diff --git a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php index 6f9ffb993..d66ba5336 100644 --- a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php @@ -452,6 +452,49 @@ public function test_composite_ansi_mode_expands_to_lexer_component_modes(): voi $this->assertSame( WP_MySQL_Lexer::COUNT_SYMBOL, $lexer->get_token()->id ); } + /** + * Quoted identifiers escape delimiters by doubling, not with backslashes. + * + * @dataProvider data_quoted_identifier_backslash + */ + public function test_quoted_identifiers_treat_backslash_literally( + string $sql, + array $sql_modes, + int $expected_token_id, + string $expected_value + ): void { + $lexer = new WP_MySQL_Lexer( $sql, 80038, $sql_modes ); + $this->assertTrue( $lexer->next_token() ); + + $token = $lexer->get_token(); + $this->assertSame( + WP_MySQL_Lexer::get_token_name( $expected_token_id ), + $token->get_name(), + $sql + ); + $this->assertSame( $expected_value, $token->get_value(), $sql ); + } + + public function data_quoted_identifier_backslash(): array { + $bs = chr( 92 ); // A single backslash. + $bt = '`'; + $dq = '"'; + $sq = "'"; + return array( + // Backtick identifiers: backslash is a literal character. + 'backtick keeps backslash-n literal' => array( $bt . 'a' . $bs . 'nb' . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs . 'nb' ), + 'backtick allows trailing backslash' => array( $bt . 'a' . $bs . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs ), + 'backtick escapes only by doubling' => array( $bt . 'a' . $bt . $bt . 'b' . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bt . 'b' ), + + // ANSI-quoted identifiers behave like backtick identifiers. + 'ansi-quoted keeps backslash literal' => array( $dq . 'a' . $bs . 'nb' . $dq, array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs . 'nb' ), + 'ansi-quoted allows trailing backslash' => array( $dq . 'a' . $bs . $dq, array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs ), + + // String literals still process backslash escapes. + 'single-quoted string escapes newline' => array( $sq . 'a' . $bs . 'nb' . $sq, array(), WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, "a\nb" ), + ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/mysql-parser/src/class-wp-mysql-lexer.php b/packages/mysql-parser/src/class-wp-mysql-lexer.php index e71c1490c..d73f88b63 100644 --- a/packages/mysql-parser/src/class-wp-mysql-lexer.php +++ b/packages/mysql-parser/src/class-wp-mysql-lexer.php @@ -1209,15 +1209,29 @@ private function read_number(): ?int { * * Rules: * 1. Quotes can be escaped by doubling them ('', "", ``). - * 2. Backslashes escape the next character, unless NO_BACKSLASH_ESCAPES is set. + * 2. In string literals, backslashes escape the next character, + * unless the NO_BACKSLASH_ESCAPES SQL mode is set. + * 3. In identifiers, backslashes are always literal and never escape. */ private function read_quoted_text(): ?int { $quote = $this->sql[ $this->bytes_already_read ]; $this->bytes_already_read += 1; // Consume the quote. - $no_backslash_escapes = $this->is_sql_mode_active( - self::SQL_MODE_NO_BACKSLASH_ESCAPES - ); + /* + * Determine whether the quote opens an identifier or a string literal. + * An identifier is quoted with a backtick or a double quote when the + * ANSI_QUOTES SQL mode is active. Otherwise, it is a string literal. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes + */ + $is_identifier_quote = '`' === $quote + || ( '"' === $quote && $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) ); + + /* + * Backslash escapes apply only to string literals, and only when the + * NO_BACKSLASH_ESCAPES SQL mode is not set. + */ + $backslash_is_escape = ! $is_identifier_quote && ! $this->no_backslash_escapes; // We need to look for the closing quote in a loop, as it can be escaped, // in which case the escape sequence is consumed and the loop continues. @@ -1230,9 +1244,9 @@ private function read_quoted_text(): ?int { $at = $quote_at; /* - * By default, quotes can be escaped with a "\". - * When NO_BACKSLASH_ESCAPES SQL mode is active, the "\" treated as - * a regular character. + * In string literals, quotes can be escaped with a backslash. When + * NO_BACKSLASH_ESCAPES SQL mode is active, the backslash is treated + * as a regular character. Identifiers never use backslash escaping. * * The quote is escaped only when the number of preceding backslashes * is odd - "\" is an escape sequence, "\\" is an escaped backslash, @@ -1243,7 +1257,7 @@ private function read_quoted_text(): ?int { * sits at the very start of the input. The `?? null` covers * positive out-of-range indexes belt-and-suspenders. */ - if ( ! $no_backslash_escapes ) { + if ( $backslash_is_escape ) { $i = 0; while ( ( $at - $i - 1 ) >= 0 && '\\' === ( $this->sql[ $at - $i - 1 ] ?? null ) ) { $i += 1; @@ -1266,10 +1280,7 @@ private function read_quoted_text(): ?int { $this->bytes_already_read = $at; - if ( - '`' === $quote - || ( '"' === $quote && $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) ) - ) { + if ( $is_identifier_quote ) { return self::BACK_TICK_QUOTED_ID; } elseif ( '"' === $quote ) { return self::DOUBLE_QUOTED_TEXT; diff --git a/packages/mysql-parser/src/class-wp-mysql-token.php b/packages/mysql-parser/src/class-wp-mysql-token.php index cc7b02ba3..db4b6e666 100644 --- a/packages/mysql-parser/src/class-wp-mysql-token.php +++ b/packages/mysql-parser/src/class-wp-mysql-token.php @@ -72,11 +72,14 @@ public function get_value(): string { $value = substr( $value, 1, -1 ); /* - * When the NO_BACKSLASH_ESCAPES SQL mode is enabled, we only need to - * handle escaped bounding quotes, as the other characters preserve - * their literal values. + * For quoted identifiers and when the NO_BACKSLASH_ESCAPES SQL mode + * is active, we only need to handle escaped bounding quotes, as all + * other characters preserve their literal values. */ - if ( $this->sql_mode_no_backslash_escapes_enabled ) { + if ( + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id + || $this->sql_mode_no_backslash_escapes_enabled + ) { return str_replace( $quote . $quote, $quote, $value ); } diff --git a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php index 94fb43d20..67ad9ef36 100644 --- a/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-parser/tests/WP_MySQL_Lexer_Tests.php @@ -593,6 +593,45 @@ public function test_composite_ansi_mode_expands_to_lexer_component_modes(): voi $this->assertSame( WP_MySQL_Lexer::KEYWORDS['COUNT'], $lexer->get_token()->id ); } + /** + * Quoted identifiers escape delimiters by doubling, not with backslashes. + * + * @dataProvider data_quoted_identifier_backslash + */ + public function test_quoted_identifiers_treat_backslash_literally( + string $sql, + array $sql_modes, + int $expected_token_id, + string $expected_value + ): void { + $lexer = new WP_MySQL_Lexer( $sql, 80400, $sql_modes ); + $this->assertTrue( $lexer->next_token() ); + + $token = $lexer->get_token(); + $this->assertSame( $expected_token_id, $token->id, $sql ); + $this->assertSame( $expected_value, $token->get_value(), $sql ); + } + + public function data_quoted_identifier_backslash(): array { + $bs = chr( 92 ); // A single backslash. + $bt = '`'; + $dq = '"'; + $sq = "'"; + return array( + // Backtick identifiers: backslash is a literal character. + 'backtick keeps backslash-n literal' => array( $bt . 'a' . $bs . 'nb' . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs . 'nb' ), + 'backtick allows trailing backslash' => array( $bt . 'a' . $bs . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs ), + 'backtick escapes only by doubling' => array( $bt . 'a' . $bt . $bt . 'b' . $bt, array(), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bt . 'b' ), + + // ANSI-quoted identifiers behave like backtick identifiers. + 'ansi-quoted keeps backslash literal' => array( $dq . 'a' . $bs . 'nb' . $dq, array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs . 'nb' ), + 'ansi-quoted allows trailing backslash' => array( $dq . 'a' . $bs . $dq, array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, 'a' . $bs ), + + // String literals still process backslash escapes. + 'single-quoted string escapes newline' => array( $sq . 'a' . $bs . 'nb' . $sq, array(), WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, "a\nb" ), + ); + } + private function get_token_names( array $token_types ): array { return array_map( function ( $token_type ) { diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index 6a78163ed..fde90dbcf 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -784,13 +784,16 @@ impl WpMySqlNativeLexer { fn read_quoted_text(&mut self) -> Option { let quote = self.byte_at(self.bytes_already_read)?; self.bytes_already_read += 1; - let no_backslash_escapes = self.is_sql_mode_active(SQL_MODE_NO_BACKSLASH_ESCAPES); + let is_identifier_quote = + quote == b'`' || (quote == b'"' && self.is_sql_mode_active(SQL_MODE_ANSI_QUOTES)); + let backslash_is_escape = + !is_identifier_quote && !self.is_sql_mode_active(SQL_MODE_NO_BACKSLASH_ESCAPES); let mut at = self.bytes_already_read; loop { at = span_until(&self.sql, at, &[quote]); - if !no_backslash_escapes { + if backslash_is_escape { let mut i = 0usize; while at > i && self.byte_at(at - i - 1) == Some(b'\\') { i += 1; @@ -816,9 +819,10 @@ impl WpMySqlNativeLexer { } self.bytes_already_read = at + 1; + if is_identifier_quote { + return Some(lex::BACK_TICK_QUOTED_ID); + } Some(match quote { - b'`' => lex::BACK_TICK_QUOTED_ID, - b'"' if self.is_sql_mode_active(SQL_MODE_ANSI_QUOTES) => lex::BACK_TICK_QUOTED_ID, b'"' => lex::DOUBLE_QUOTED_TEXT, _ => lex::SINGLE_QUOTED_TEXT, })