From 46888d86f8505275bb3921df78ec01a456a4ccc1 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 | 123 ++++++++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 38 ++++++ .../WP_MySQL_On_SQLite_Translation_Tests.php | 6 +- 3 files changed, 150 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..faed240e7 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,63 @@ 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. Bit 4 is unused. + * + * 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, + '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, + 'TIME_TRUNCATE_FRACTIONAL' => 1 << 32, + ); + + /** + * The default SQL modes for MySQL 8.0. + */ + 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 +648,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 +756,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( self::DEFAULT_SQL_MODES ); // Check the database name. if ( '' === $this->db_name ) { @@ -1152,7 +1203,9 @@ 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 ); + return isset( self::SQL_MODES[ $mode ] ) + && ( $this->active_sql_modes & self::SQL_MODES[ $mode ] ) !== 0; } /** @@ -1196,7 +1249,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 +3577,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 = self::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; } @@ -3540,6 +3599,26 @@ private function execute_set_system_variable_statement( // TODO: Handle GLOBAL, PERSIST, and PERSIST_ONLY types. } + /** + * 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; + } + /** * Translate and execute a MySQL SET statement for user variables. * @@ -3876,7 +3955,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( @@ -3968,6 +4047,22 @@ private function translate( $node ): ?string { } } + /** + * 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 ) { + $active_modes[] = $mode; + } + } + + return $active_modes; + } + /** * Translate a MySQL token to SQLite. * 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..3e6eb926d 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,44 @@ 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 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 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 be9410526d944999417d6348de9fe68f7cd51d52 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 | 109 ++++++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 129 ++++++++++++++++++ 2 files changed, 233 insertions(+), 5 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 faed240e7..ff554430e 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 @@ -3571,7 +3571,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; } @@ -3580,7 +3580,7 @@ private function execute_set_system_variable_statement( if ( null !== $value_node->get_first_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { $sql_modes = self::DEFAULT_SQL_MODES; } elseif ( is_string( $value ) ) { - $sql_modes = explode( ',', $value ); + $sql_modes = explode( ',', rtrim( $value, ' ' ) ); } else { $sql_modes = $value; } @@ -3606,19 +3606,118 @@ private function execute_set_system_variable_statement( */ private function set_sql_modes( $modes ): void { if ( is_int( $modes ) ) { + if ( $modes < 0 || ( $modes & ~array_sum( self::SQL_MODES ) ) !== 0 ) { + throw $this->new_invalid_sql_mode_exception( $modes ); + } + + $unsupported_sql_modes = 0; + foreach ( self::SQL_MODES as $mode => $value ) { + if ( ( $modes & $value ) !== 0 && ! $this->is_sql_mode_supported( $mode ) ) { + $unsupported_sql_modes |= $value; + } + } + + if ( 0 !== $unsupported_sql_modes ) { + if ( $this->mysql_version >= 80011 ) { + throw $this->new_driver_exception( + sprintf( + 'SQLSTATE[HY000]: General error: 3899 sql_mode=0x%08x is not supported.', + $unsupported_sql_modes + ), + 'HY000' + ); + } + + throw $this->new_invalid_sql_mode_exception( $modes ); + } + $this->active_sql_modes = $modes; return; } + if ( null === $modes ) { + throw $this->new_invalid_sql_mode_exception( $modes ); + } + + if ( ! is_array( $modes ) ) { + throw $this->new_driver_exception( + "SQLSTATE[42000]: Syntax error or access violation: 1232 Incorrect argument type to variable 'sql_mode'", + '42000' + ); + } + $sql_modes = 0; - foreach ( (array) $modes as $mode ) { - $mode = strtoupper( trim( $mode ) ); - $sql_modes |= self::SQL_MODES[ $mode ] ?? 0; + foreach ( $modes as $mode ) { + if ( '' === $mode ) { + continue; + } + + $normalized_mode = strtoupper( $mode ); + if ( ! $this->is_sql_mode_supported( $normalized_mode ) ) { + throw $this->new_invalid_sql_mode_exception( $mode ); + } + + $sql_modes |= self::SQL_MODES[ $normalized_mode ]; } $this->active_sql_modes = $sql_modes; } + /** + * 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' + ); + } + + /** + * Check whether an SQL mode is supported by the emulated MySQL version. + * + * @param string $mode Normalized SQL mode name. + * @return bool Whether the SQL mode is supported. + */ + private function is_sql_mode_supported( string $mode ): bool { + if ( ! isset( self::SQL_MODES[ $mode ] ) ) { + return false; + } + + if ( $this->mysql_version < 80001 && 'TIME_TRUNCATE_FRACTIONAL' === $mode ) { + return false; + } + + /* + * 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 + ); + } + /** * Translate and execute a MySQL SET statement for user variables. * 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 3e6eb926d..079ad4cf7 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 @@ -2887,6 +2887,61 @@ public function testSqlModesAcceptNumericBitmap() { ); } + 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 ); + + $exception = null; + try { + $this->query( "SET sql_mode = 'TIME_TRUNCATE_FRACTIONAL'" ); + } catch ( WP_SQLite_Driver_Exception $e ) { + $exception = $e; + } + + $this->assertInstanceOf( WP_SQLite_Driver_Exception::class, $exception ); + $this->assertSame( + "SQLSTATE[42000]: Syntax error or access violation: 1231 Variable 'sql_mode' can't be set to the value of 'TIME_TRUNCATE_FRACTIONAL'", + $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 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' ); @@ -2898,6 +2953,80 @@ public function testSqlModeDefaultRestoresDefaultBitmap() { ); } + /** + * @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 fff7ff8ca82255eb853725ff9f866d3614d6d149 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 | 35 ++++++++++++ .../tests/mysql/WP_MySQL_Lexer_Tests.php | 57 +++++++++++++++++++ .../src/lexer_constants.rs | 5 ++ packages/php-ext-wp-mysql-parser/src/lib.rs | 3 + .../tools/generate-lexer-constants.php | 2 +- 6 files changed, 114 insertions(+), 2 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..d22a8432b 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 active, a double-quoted sequence is a + * quoted identifier rather than a string literal, so it is emitted as + * a backtick-quoted identifier token. + * + * 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 079ad4cf7..36eefbe8b 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 @@ -3091,6 +3091,41 @@ public function testNoAutoValueOnZeroSqlMode() { $this->assertEquals( 1, $results[0]->ID ); } + public function testDoubleQuotesAreStringLiteralsByDefault() { + // Without ANSI_QUOTES, a double-quoted sequence is a string literal. + $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');" + ); + + // Double quotes are identifiers; single quotes are still string literals. + $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..e1b0e093f 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,63 @@ public function data_underscore_charset_after_dot(): array { ); } + /** + * With the ANSI_QUOTES SQL mode active, a double-quoted sequence is a quoted + * identifier rather than a string literal. Single quotes and backticks keep + * their usual meaning. + * + * @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( + // By default, a double-quoted sequence is a string literal. + 'double quote is a string by default' => array( '"foo"', array(), WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT ), + + // With ANSI_QUOTES, a double-quoted sequence is a quoted identifier. + 'double quote is an identifier in ANSI_QUOTES' => array( '"foo"', array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + + // SQL mode names are case-insensitive. + 'ANSI_QUOTES is case-insensitive' => array( '"foo"', array( 'ansi_quotes' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + + // ANSI_QUOTES alongside other modes still applies. + 'ANSI_QUOTES with other modes' => array( '"foo"', array( 'STRICT_ALL_TABLES', 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + + // Single quotes remain string literals under ANSI_QUOTES. + 'single quote stays a string in ANSI_QUOTES' => array( "'foo'", array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ), + + // Backticks remain identifiers under ANSI_QUOTES. + 'backtick stays an identifier in ANSI_QUOTES' => array( '`foo`', array( 'ANSI_QUOTES' ), WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), + ); + } + + /** + * An ANSI-quoted identifier escapes the bounding double quote by doubling it, + * just like a backtick identifier doubles the backtick. The unquoted value + * must collapse the doubled quotes. + */ + 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/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 787c890b93d896582bc9ea9d70c03638468fb8bf 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 | 10 +++++ .../src/sqlite/class-wp-mysql-on-sqlite.php | 32 ++++++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 39 +++++++++++++++++++ .../tests/mysql/WP_MySQL_Lexer_Tests.php | 22 +++++++++++ packages/php-ext-wp-mysql-parser/src/lib.rs | 3 ++ 5 files changed, 104 insertions(+), 2 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 d22a8432b..ddb37d2d6 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,16 @@ 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 ) { + /* + * The composite 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 ff554430e..9b014e3e9 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 @@ -3631,7 +3631,7 @@ private function set_sql_modes( $modes ): void { throw $this->new_invalid_sql_mode_exception( $modes ); } - $this->active_sql_modes = $modes; + $this->active_sql_modes = $this->expand_sql_modes( $modes ); return; } @@ -3660,7 +3660,7 @@ private function set_sql_modes( $modes ): void { $sql_modes |= self::SQL_MODES[ $normalized_mode ]; } - $this->active_sql_modes = $sql_modes; + $this->active_sql_modes = $this->expand_sql_modes( $sql_modes ); } /** @@ -3718,6 +3718,34 @@ private function is_sql_mode_supported( string $mode ): bool { ); } + /** + * Expand composite SQL modes while retaining the composite modes themselves. + * + * Some MySQL SQL modes are shorthands that enable a set of other modes. + * MySQL retains each composite mode when sql_mode is set, enables its + * component modes, and serializes the result in bitmask order. We do the same + * so that "@@sql_mode" and individual mode checks reflect both, 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 + * + * @param int $sql_modes SQL mode bitmask to expand. + * @return int The expanded SQL mode bitmask. + */ + private function expand_sql_modes( int $sql_modes ): int { + 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']; + } + + return $sql_modes; + } + /** * Translate and execute a MySQL SET statement for user variables. * 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 36eefbe8b..e3bfe5291 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 @@ -3126,6 +3126,45 @@ 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');" + ); + + // The composite ANSI mode implies ANSI_QUOTES. + $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 e1b0e093f..383bad034 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 @@ -455,6 +455,28 @@ public function test_ansi_quotes_identifier_value_is_unescaped(): void { $this->assertSame( 'a"b', $token->get_value() ); } + /** + * The composite ANSI SQL mode expands to a set of component modes. We expand + * it to the components that affect the lexer: ANSI_QUOTES, PIPES_AS_CONCAT, + * and IGNORE_SPACE. + */ + 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/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 8f7b676ce1a2c9bf85307e79659c28712553a124 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 | 45 +++++++++--------- .../src/mysql/class-wp-mysql-token.php | 12 +++-- .../tests/mysql/WP_MySQL_Lexer_Tests.php | 46 +++++++++++++++++++ packages/php-ext-wp-mysql-parser/src/lib.rs | 12 +++-- 4 files changed, 85 insertions(+), 30 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 ddb37d2d6..15e24b1ee 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 @@ -2904,15 +2904,26 @@ 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. In quoted identifiers, backslashes + * are always literal; only doubling the quote escapes it. */ 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 - ); + /* + * A backtick always quotes an identifier, and a double quote quotes an + * identifier under ANSI_QUOTES; otherwise it opens a string literal. + * Backslash escapes apply only to string literals, and only when the + * NO_BACKSLASH_ESCAPES SQL mode is not set. + * + * 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_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. @@ -2925,9 +2936,10 @@ 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, a quote can be escaped with a "\", unless the + * NO_BACKSLASH_ESCAPES SQL mode is active, in which case the "\" is + * treated as a regular character. Quoted 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, @@ -2938,7 +2950,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; @@ -2961,22 +2973,11 @@ private function read_quoted_text(): ?int { $this->bytes_already_read = $at; - if ( '`' === $quote ) { + // Identifiers (backtick, or double quote under ANSI_QUOTES) share a token type. + if ( $is_identifier_quote ) { return self::BACK_TICK_QUOTED_ID; - } elseif ( '"' === $quote ) { - /* - * With the ANSI_QUOTES SQL mode active, a double-quoted sequence is a - * quoted identifier rather than a string literal, so it is emitted as - * a backtick-quoted identifier token. - * - * 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..23048589b 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,15 @@ 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. + * Quoted identifiers never use backslash escaping, and when the + * NO_BACKSLASH_ESCAPES SQL mode is enabled string literals don't + * either. In both cases, we only need to handle escaped bounding + * quotes, as the 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 383bad034..1e206d57a 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 @@ -477,6 +477,52 @@ public function test_composite_ansi_mode_expands_to_lexer_component_modes(): voi $this->assertSame( WP_MySQL_Lexer::COUNT_SYMBOL, $lexer->get_token()->id ); } + /** + * MySQL does not process backslash escape sequences inside quoted identifiers; + * the bounding quote is escaped only by doubling it. This applies to backtick + * identifiers and, under ANSI_QUOTES, to double-quoted identifiers. String + * literals are unaffected and keep their usual backslash escaping. + * + * @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/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, })