Skip to content

Commit 7020813

Browse files
authored
fix: lex MySQL backslash escapes consistently in string literals (#2790)
With backslash escapes enabled, as in the MySQL and MariaDB presets, the lexer recognized only a few escapes and no doubled quote in the same literal. 'it\'s ''x''' silently split into a string and an alias, 'a\0b\'c', '\Z\'' and 'a''b\'c' failed, and the double-quoted "it\"s" ended at the escaped quote. Let both quote styles read any backslash escape and doubled quotes, then trim the token to the closing quote of the rules in effect. A backslash only escapes with backslash escapes enabled or in PostgreSQL E'' strings, so standard strings, LIKE ... ESCAPE '\' and quoted identifiers such as "col\" are unchanged. Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent aa9ecfe commit 7020813

3 files changed

Lines changed: 121 additions & 46 deletions

File tree

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2462,26 +2462,33 @@ TOKEN_MGR_DECLS : {
24622462
}
24632463
}
24642464

2465-
// Finds first occurrence of "\\'"
2466-
public static int indexOfSequence(String s, String target) {
2467-
int len = s.length();
2468-
for (int i = 0; i < len - 1; i++) {
2469-
if (s.charAt(i) == '\\' && s.charAt(i + 1) == '\'') {
2470-
return i;
2465+
// Returns the length up to the closing quote of a quoted literal starting at open, or -1 when
2466+
// the image is unterminated. A doubled quote is literal; a backslash escapes the next character
2467+
// only if requested.
2468+
public static int closingQuote(String s, int open, char quote, boolean backslashEscapes) {
2469+
int i = open + 1;
2470+
while (i < s.length()) {
2471+
char c = s.charAt(i);
2472+
if (backslashEscapes && c == '\\' && i + 1 < s.length()) {
2473+
i += 2;
2474+
} else if (c == quote && i + 1 < s.length() && s.charAt(i + 1) == quote) {
2475+
i += 2;
2476+
} else if (c == quote) {
2477+
return i + 1;
2478+
} else {
2479+
i++;
24712480
}
24722481
}
24732482
return -1;
24742483
}
24752484

2476-
// Finds last occurrence of "\\''"
2477-
public static int lastIndexOfSequence(String s, String target) {
2478-
int len = s.length();
2479-
for (int i = len - 3; i >= 0; i--) {
2480-
if (s.charAt(i) == '\\' && s.charAt(i + 1) == '\'' && s.charAt(i + 2) == '\'') {
2481-
return i;
2482-
}
2485+
// Like closingQuote for a single-quoted literal after its optional prefix. Oracle q'..'
2486+
// literals have their own delimiters and are left unchanged.
2487+
public static int closingSingleQuote(String s, boolean backslashEscapes) {
2488+
if (s.length() > 1 && (s.charAt(0) == 'q' || s.charAt(0) == 'Q') && s.charAt(1) == '\'') {
2489+
return -1;
24832490
}
2484-
return -1;
2491+
return closingQuote(s, s.indexOf('\''), '\'', backslashEscapes);
24852492
}
24862493

24872494
private RowPatternTokenContext rowPatternContext;
@@ -3442,12 +3449,11 @@ TOKEN:
34423449
// CJK Unified Ideographs block according to https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block)
34433450
| <#CJK: ["\uAC00"-"\uD7A3", "\u4E00"-"\u9FFF"]>
34443451

3445-
| < #SPECIAL_ESC: "\\'" > /* Allowing this will break LIKE ... ESCAPE ... */
3446-
| < #ESC: "\\" ["n","t","b","r","f","\\","\""] >
34473452
| < S_CHAR_LITERAL: (
34483453
(["U","E","N","R","B"]|"RB"|"_utf8")?
34493454
(
3450-
("'" ( <ESC> | <SPECIAL_ESC> | ~["'", "\\"] )* "'") | ("'" ("''" | ~["'"])* "'")
3455+
// Backslash escapes and doubled quotes; trimmed below to the rules in effect
3456+
("'" ( "\\" ~[] | "''" | ~["'", "\\"] )* "'") | ("'" ("''" | ~["'"])* "'")
34513457
// Alternative Oracle Escape Modes
34523458
| ("q'{" (~[])* "}'")
34533459
| ("q'(" (~[])* ")'")
@@ -3457,36 +3463,36 @@ TOKEN:
34573463
)
34583464
) >
34593465
{
3460-
// <S_CHAR_LITERAL> contains the <SPECIAL_ESC> token and always the longest match is returned
3461-
// So when Backslash is explicitly not allowed as an Escape Character and a <S_CHAR_LITERAL> is found
3462-
// which contains the <SPECIAL_ESC>, then we will need to
3463-
// 1) break the <S_CHAR_LITERAL> at <SPECIAL_ESC> close it with a "'"
3464-
// 2) continue tokenizing after that <SPECIAL_ESC> with a new <S_CHAR_LITERAL> or any other Token
3465-
boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter)
3466+
// The longest match may run past the closing quote of the active rules. A backslash
3467+
// escapes the next character only with backslash escapes or in a PostgreSQL E'' string;
3468+
// otherwise the literal ends at the first quote that is not doubled, so LIKE ... ESCAPE '\\'
3469+
// still works.
3470+
boolean backslashEscapes = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter)
34663471
|| (AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect))
34673472
&& matchedToken.image.regionMatches(true, 0, "E'", 0, 2));
3468-
String img = matchedToken.image;
3469-
int pos;
3470-
if (!allowEscape) {
3471-
pos = indexOfSequence(img, "\\'");
3472-
if (pos > 0) {
3473-
matchedToken.image = image.substring( 0, image.indexOf("\\'") + 1 ) + "'";
3474-
// `charLiteralIndex` defined in TokenManagerDeclaration above
3475-
matchedToken.kind = charLiteralIndex;
3476-
input_stream.backup(image.length() - matchedToken.image.length());
3477-
}
3478-
} else {
3479-
pos = lastIndexOfSequence(img, "\\''");
3480-
if (pos > 0) {
3481-
matchedToken.image = image.substring( 0, image.lastIndexOf("\\'") + 3);
3482-
// `charLiteralIndex` defined in TokenManagerDeclaration above
3483-
matchedToken.kind = charLiteralIndex;
3484-
input_stream.backup(image.length() - matchedToken.image.length() );
3485-
}
3486-
}
3487-
}
3488-
| < S_QUOTED_IDENTIFIER: "\"" ( "\"\"" | ~["\n","\r","\""])* "\"" | ("`" ("``" | ~["\n","\r","`"])* "`") | ( "[" (~["\n","\r","]"])* "]" ) >
3489-
{
3473+
int end = closingSingleQuote(matchedToken.image, backslashEscapes);
3474+
if (end > 0 && end < matchedToken.image.length()) {
3475+
matchedToken.image = image.substring(0, end);
3476+
input_stream.backup(image.length() - end);
3477+
}
3478+
}
3479+
| < S_QUOTED_IDENTIFIER: "\"" ( "\"\"" | ~["\n","\r","\""])* "\""
3480+
// MySQL double-quoted string with backslash escapes; trimmed below when not enabled
3481+
| "\"" ( "\"\"" | "\\" ~["\n","\r"] | ~["\n","\r","\"","\\"])* "\""
3482+
| ("`" ("``" | ~["\n","\r","`"])* "`") | ( "[" (~["\n","\r","]"])* "]" ) >
3483+
{
3484+
if ( matchedToken.image.charAt(0) == '"' ) {
3485+
// Both alternatives above compete for the longest match. Keep the closing quote of
3486+
// the active quoting rules: a backslash escapes the next character only in
3487+
// double-quoted strings with backslash escapes, as in MySQL.
3488+
int end = closingQuote(matchedToken.image, 0, '"',
3489+
configuration.getAsBoolean(Feature.allowDoubleQuotedStrings)
3490+
&& configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter));
3491+
if (end > 0 && end < matchedToken.image.length()) {
3492+
matchedToken.image = image.substring(0, end);
3493+
input_stream.backup(image.length() - end);
3494+
}
3495+
}
34903496
if ( !configuration.getAsBoolean(Feature.allowSquareBracketQuotation)
34913497
&& matchedToken.image.charAt(0) == '[' ) {
34923498

‎src/site/sphinx/usage.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1568,7 +1568,7 @@ The individual features
15681568
* - ``withBackslashEscapeCharacter``
15691569
- ``\\..`` escaping inside string literals, in addition to the standard ``'..`` doubling
15701570
* - ``withDoubleQuotedStrings``
1571-
- ``".."`` reads as a string literal instead of a quoted identifier (BigQuery, Spark/Databricks, MySQL default ``sql_mode``)
1571+
- ``".."`` reads as a string literal instead of a quoted identifier (BigQuery, Spark/Databricks, MySQL default ``sql_mode``); combined with ``withBackslashEscapeCharacter``, ``\\"`` does not end the literal, as in MySQL ``"it\\"s"``
15721572
* - ``withHashLineComments``
15731573
- ``#`` starts a line comment
15741574
* - ``withAdjacentStringLiterals``

‎src/test/java/net/sf/jsqlparser/parser/CCJSqlParserUtilTest.java‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,75 @@ public void testDoubleQuotedStringsPreset() throws Exception {
748748
}
749749
}
750750

751+
@Test
752+
public void testDoubleQuotedStringsWithBackslashEscapes() throws Exception {
753+
// MySQL reads "it\"s" as it"s; the escaped quote must not end the literal
754+
for (AbstractJSqlParser.Dialect dialect : new AbstractJSqlParser.Dialect[] {
755+
AbstractJSqlParser.Dialect.MYSQL, AbstractJSqlParser.Dialect.MARIADB,
756+
AbstractJSqlParser.Dialect.BIGQUERY, AbstractJSqlParser.Dialect.DATABRICKS}) {
757+
PlainSelect select = (PlainSelect) ((Select) CCJSqlParserUtil.parse(
758+
"SELECT \"it\\\"s\", \"a\\\\\"", p -> p.withDialect(dialect)))
759+
.getSelectBody();
760+
assertEquals("it\\\"s",
761+
((StringValue) select.getSelectItems().get(0).getExpression()).getValue());
762+
assertEquals("a\\\\",
763+
((StringValue) select.getSelectItems().get(1).getExpression()).getValue());
764+
assertEquals("SELECT \"it\\\"s\", \"a\\\\\"", select.toString());
765+
}
766+
// a doubled quote still continues a MySQL literal
767+
assertEquals("SELECT \"b\"\"c\\\"d\"", CCJSqlParserUtil.parse("SELECT \"b\"\"c\\\"d\"",
768+
p -> p.withDialect(AbstractJSqlParser.Dialect.MYSQL)).toString());
769+
String alter = "ALTER TABLE t ADD COLUMN s VARCHAR (10) DEFAULT \"it\\\"s\"";
770+
assertEquals(alter, CCJSqlParserUtil
771+
.parse(alter, p -> p.withDialect(AbstractJSqlParser.Dialect.MYSQL)).toString());
772+
// the same escape needs both features
773+
assertEquals("SELECT \"it\\\"s\"", CCJSqlParserUtil.parse("SELECT \"it\\\"s\"",
774+
p -> p.withDoubleQuotedStrings(true).withBackslashEscapeCharacter(true))
775+
.toString());
776+
assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil
777+
.parse("SELECT \"it\\\"s\"", p -> p.withDoubleQuotedStrings(true)));
778+
// MySQL closes "a\"" before b, so the rest is unterminated as on the server
779+
assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(
780+
"SELECT \"a\\\"\"b\"", p -> p.withDialect(AbstractJSqlParser.Dialect.MYSQL)));
781+
// quoted identifiers keep a trailing backslash
782+
for (AbstractJSqlParser.Dialect dialect : new AbstractJSqlParser.Dialect[] {null,
783+
AbstractJSqlParser.Dialect.POSTGRESQL, AbstractJSqlParser.Dialect.SNOWFLAKE}) {
784+
PlainSelect select = (PlainSelect) ((Select) CCJSqlParserUtil.parse(
785+
"SELECT \"col\\\" FROM \"t\"", p -> {
786+
if (dialect != null) {
787+
p.withDialect(dialect);
788+
}
789+
})).getSelectBody();
790+
assertEquals("\"col\\\"",
791+
((Column) select.getSelectItems().get(0).getExpression()).getColumnName());
792+
assertEquals("SELECT \"col\\\" FROM \"t\"", select.toString());
793+
}
794+
}
795+
796+
@Test
797+
public void testSingleQuotedStringsMixBackslashEscapesAndDoubledQuotes() throws Exception {
798+
// each is one MySQL string; the first used to split into a string and an alias
799+
for (String literal : new String[] {"'it\\'s ''x'''", "'a\\0b\\'c'", "'\\Z\\''",
800+
"'a''b\\'c'", "'x\\%\\'y'", "N'it\\'s'"}) {
801+
PlainSelect select = (PlainSelect) ((Select) CCJSqlParserUtil.parse(
802+
"SELECT " + literal + " FROM t",
803+
p -> p.withDialect(AbstractJSqlParser.Dialect.MYSQL))).getSelectBody();
804+
assertEquals(1, select.getSelectItems().size(), literal);
805+
assertNull(select.getSelectItems().get(0).getAlias(), literal);
806+
assertInstanceOf(StringValue.class, select.getSelectItems().get(0).getExpression());
807+
assertEquals("SELECT " + literal + " FROM t", select.toString());
808+
}
809+
// PostgreSQL E'' strings escape quotes; standard strings keep a literal backslash
810+
assertEquals("SELECT E'it\\'s ''x'''", CCJSqlParserUtil.parse("SELECT E'it\\'s ''x'''",
811+
p -> p.withDialect(AbstractJSqlParser.Dialect.POSTGRESQL)).toString());
812+
assertEquals("SELECT 'a\\', 'b'", CCJSqlParserUtil.parse("SELECT 'a\\', 'b'",
813+
p -> p.withDialect(AbstractJSqlParser.Dialect.POSTGRESQL)).toString());
814+
// without backslash escapes the literal still ends at the escaped-looking quote
815+
assertEquals("SELECT 'a\\' AS x", CCJSqlParserUtil.parse("SELECT 'a\\' AS x").toString());
816+
assertEquals("SELECT * FROM t WHERE a LIKE 'x\\_%' ESCAPE '\\'", CCJSqlParserUtil
817+
.parse("SELECT * FROM t WHERE a LIKE 'x\\_%' ESCAPE '\\'").toString());
818+
}
819+
751820
@Test
752821
public void testAdjacentStringLiteralsNewline() throws Exception {
753822
// default (off): the MySQL / SQL Server alias reading, unchanged

0 commit comments

Comments
 (0)