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..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 @@ -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,18 @@ 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; + } 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; } } } @@ -2891,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. @@ -2912,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, @@ -2925,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; @@ -2948,13 +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 ) { - return 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/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index c8f20e27a..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 @@ -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() @@ -3518,14 +3571,20 @@ 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; } 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( ',', rtrim( $value, ' ' ) ); + } else { + $sql_modes = $value; + } + $this->set_sql_modes( $sql_modes ); } else { $this->session_system_variables[ $name ] = $value; } @@ -3540,6 +3599,153 @@ 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 ) ) { + 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 = $this->expand_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 ( $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 = $this->expand_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 + ); + } + + /** + * 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. * @@ -3876,7 +4082,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 +4174,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..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 @@ -2860,6 +2860,173 @@ 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 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' ); + + $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 + ); + } + + /** + * @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. @@ -2924,6 +3091,80 @@ 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 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/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' ); } 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..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 @@ -398,6 +398,131 @@ 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() ); + } + + /** + * 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 ); + } + + /** + * 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/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..fde90dbcf 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,10 @@ 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, + "ANSI" => { + mask |= SQL_MODE_PIPES_AS_CONCAT | SQL_MODE_IGNORE_SPACE | SQL_MODE_ANSI_QUOTES + } _ => {} } } @@ -779,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; @@ -811,8 +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'"' => 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();