Add ANSI_QUOTES and SQL mode validation - #452
Conversation
🤖 Lexer benchmarkChanges to lexer-related files were detected and triggered a benchmark:
Note: Hosted runners are noisy, and absolute numbers vary. Treat the results with caution and verify them locally. To reproduce locally: |
b8d6367 to
3f11622
Compare
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.
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.
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
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
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 "a<newline>b", 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
brandonpayton
left a comment
There was a problem hiding this comment.
Hi @JanJakes, I read and reviewed this and left some notes and questions. I'm not confident enough in how everything works to say one way or the other today. But everything looks reasonable to me. Thanks!
| 'a"b', | ||
| self::first_token( 'SELECT "a""b"', 'BACK_TICK_QUOTED_ID', array( 'ANSI_QUOTES' ) )->get_value() |
There was a problem hiding this comment.
How does this reduce a""b to a"b? I think this is probably expected ANSI SQL behavior, but is there any standard we can link to somewhere that says this explicitly?
IIUC, the actual ANSI SQL standard is not publicly available, but is available for purchase. So I was trying to think what kind of doc we could refer to as an explanation.
Also, if this seems silly to you, please ignore it. I just had a question about the quote handling and thought it would be cool if we showed why it is what it is.
There was a problem hiding this comment.
@brandonpayton I'm not sure if this is part of the ANSI standard (AI says it actually is), but quoted identifiers in MySQL escape the quote character by doubling it (backslashes don't work in identifiers). So if you want to express a table name like myˋtable in a query, you'd need to write SELECT * FROM ˋmyˋˋtableˋ.
With the ANSI_QUOTES mode enabled, double quotes behave like backticks, so for a table name like my"table, you'd write SELECT * FROM "my""table" (or SELECT * FROM ˋmy"tableˋ).
I guess the MySQL docs are something we could link, as they say:
If the character to be included within the identifier is the same as that used to quote the identifier itself, then you need to double the character.
I'll review how this is documented in the code.
| 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( ',', rtrim( $value, ' ' ) ); |
There was a problem hiding this comment.
Naive question:
Why just rtrim() and not trim()?
There was a problem hiding this comment.
This just mirrors MySQL behavior. For some reason, MySQL trims only the trailing space, so 'ANSI_QUOTES ' is interpreted as 'ANSI_QUOTES', but ' ANSI_QUOTES' is invalid. Good question, though; I need to document this better.
| } | ||
|
|
||
| public function testSqlModesAcceptNumericBitmap() { | ||
| $this->assertQuery( 'SET sql_mode = 4294967299' ); |
There was a problem hiding this comment.
Nit: It might be nice to compose the number of specific bits so a human can better confirm how the expected mode corresponds to the assigned value. Same for other places mode numbers are used in these tests.
| $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' ) ); |
There was a problem hiding this comment.
What does this do?
I think this sets the mode to:
'ONLY_FULL_GROUP_BY' => 1 << 5,
Why would $this->last_result[0]->mode equal ','?
There was a problem hiding this comment.
Ah, that's an edge case that the agent covered in tests. 16 is 1 << 4, which is 'NOT_USED'. In MySQL 5.7, it's literally named ,, under MySQL 8, it's named NOT_USED. I'll document it better in the test.
|
|
||
| $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', |
There was a problem hiding this comment.
Is there a reasonable to way to assert against the actual default bitmap rather than a copy? Is there a reason it would not be a good idea?
There was a problem hiding this comment.
I think the explicit value is preferable here. The test is verifying MySQL default, including the canonical order. This ensures we don't accidentally change the default to something else.
| $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""";' ); |
There was a problem hiding this comment.
What identifier does ""tbl""" represent if two double quotes are combined to make one double quote?
There was a problem hiding this comment.
It represents the table name my "tbl" (quotes within identifiers are escaped by doubling them; see also #452 (comment)). I'll see if I can use a better identifier and/or document it better.
Summary
This PR improves SQL mode handling and adds
ANSI_QUOTESandANSIIsupport. This includes:ANSI_QUOTESis supported by the driver, standalone parser, and native lexer.ANSISQL mode is expanded into its component modes.Why
The
ANSI_QUOTESSQL mode support keeps reappearing in our recent performance optimization and parser experiments. This PR extracts and completes that functionality.Additionally, the driver stored arbitrary mode names, accepted invalid assignments, always treated double quotes as string delimiters, did not expand did not expand composite SQL modes, and applied string escaping to quoted identifiers. The standalone parser and native lexer lacked equivalent quoted-identifier handling.