Skip to content

Commit 8c135ed

Browse files
committed
fix: lex MySQL backslash escapes consistently in string literals
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 166f0a2 commit 8c135ed

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
@@ -2275,26 +2275,33 @@ TOKEN_MGR_DECLS : {
22752275
}
22762276
}
22772277

2278-
// Finds first occurrence of "\\'"
2279-
public static int indexOfSequence(String s, String target) {
2280-
int len = s.length();
2281-
for (int i = 0; i < len - 1; i++) {
2282-
if (s.charAt(i) == '\\' && s.charAt(i + 1) == '\'') {
2283-
return i;
2278+
// Returns the length up to the closing quote of a quoted literal starting at open, or -1 when
2279+
// the image is unterminated. A doubled quote is literal; a backslash escapes the next character
2280+
// only if requested.
2281+
public static int closingQuote(String s, int open, char quote, boolean backslashEscapes) {
2282+
int i = open + 1;
2283+
while (i < s.length()) {
2284+
char c = s.charAt(i);
2285+
if (backslashEscapes && c == '\\' && i + 1 < s.length()) {
2286+
i += 2;
2287+
} else if (c == quote && i + 1 < s.length() && s.charAt(i + 1) == quote) {
2288+
i += 2;
2289+
} else if (c == quote) {
2290+
return i + 1;
2291+
} else {
2292+
i++;
22842293
}
22852294
}
22862295
return -1;
22872296
}
22882297

2289-
// Finds last occurrence of "\\''"
2290-
public static int lastIndexOfSequence(String s, String target) {
2291-
int len = s.length();
2292-
for (int i = len - 3; i >= 0; i--) {
2293-
if (s.charAt(i) == '\\' && s.charAt(i + 1) == '\'' && s.charAt(i + 2) == '\'') {
2294-
return i;
2295-
}
2298+
// Like closingQuote for a single-quoted literal after its optional prefix. Oracle q'..'
2299+
// literals have their own delimiters and are left unchanged.
2300+
public static int closingSingleQuote(String s, boolean backslashEscapes) {
2301+
if (s.length() > 1 && (s.charAt(0) == 'q' || s.charAt(0) == 'Q') && s.charAt(1) == '\'') {
2302+
return -1;
22962303
}
2297-
return -1;
2304+
return closingQuote(s, s.indexOf('\''), '\'', backslashEscapes);
22982305
}
22992306

23002307
private RowPatternTokenContext rowPatternContext;
@@ -3222,12 +3229,11 @@ TOKEN:
32223229
// CJK Unified Ideographs block according to https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block)
32233230
| <#CJK: ["\uAC00"-"\uD7A3", "\u4E00"-"\u9FFF"]>
32243231

3225-
| < #SPECIAL_ESC: "\\'" > /* Allowing this will break LIKE ... ESCAPE ... */
3226-
| < #ESC: "\\" ["n","t","b","r","f","\\","\""] >
32273232
| < S_CHAR_LITERAL: (
32283233
(["U","E","N","R","B"]|"RB"|"_utf8")?
32293234
(
3230-
("'" ( <ESC> | <SPECIAL_ESC> | ~["'", "\\"] )* "'") | ("'" ("''" | ~["'"])* "'")
3235+
// Backslash escapes and doubled quotes; trimmed below to the rules in effect
3236+
("'" ( "\\" ~[] | "''" | ~["'", "\\"] )* "'") | ("'" ("''" | ~["'"])* "'")
32313237
// Alternative Oracle Escape Modes
32323238
| ("q'{" (~[])* "}'")
32333239
| ("q'(" (~[])* ")'")
@@ -3237,36 +3243,36 @@ TOKEN:
32373243
)
32383244
) >
32393245
{
3240-
// <S_CHAR_LITERAL> contains the <SPECIAL_ESC> token and always the longest match is returned
3241-
// So when Backslash is explicitly not allowed as an Escape Character and a <S_CHAR_LITERAL> is found
3242-
// which contains the <SPECIAL_ESC>, then we will need to
3243-
// 1) break the <S_CHAR_LITERAL> at <SPECIAL_ESC> close it with a "'"
3244-
// 2) continue tokenizing after that <SPECIAL_ESC> with a new <S_CHAR_LITERAL> or any other Token
3245-
boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter)
3246+
// The longest match may run past the closing quote of the active rules. A backslash
3247+
// escapes the next character only with backslash escapes or in a PostgreSQL E'' string;
3248+
// otherwise the literal ends at the first quote that is not doubled, so LIKE ... ESCAPE '\\'
3249+
// still works.
3250+
boolean backslashEscapes = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter)
32463251
|| (AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect))
32473252
&& matchedToken.image.regionMatches(true, 0, "E'", 0, 2));
3248-
String img = matchedToken.image;
3249-
int pos;
3250-
if (!allowEscape) {
3251-
pos = indexOfSequence(img, "\\'");
3252-
if (pos > 0) {
3253-
matchedToken.image = image.substring( 0, image.indexOf("\\'") + 1 ) + "'";
3254-
// `charLiteralIndex` defined in TokenManagerDeclaration above
3255-
matchedToken.kind = charLiteralIndex;
3256-
input_stream.backup(image.length() - matchedToken.image.length());
3257-
}
3258-
} else {
3259-
pos = lastIndexOfSequence(img, "\\''");
3260-
if (pos > 0) {
3261-
matchedToken.image = image.substring( 0, image.lastIndexOf("\\'") + 3);
3262-
// `charLiteralIndex` defined in TokenManagerDeclaration above
3263-
matchedToken.kind = charLiteralIndex;
3264-
input_stream.backup(image.length() - matchedToken.image.length() );
3265-
}
3266-
}
3267-
}
3268-
| < S_QUOTED_IDENTIFIER: "\"" ( "\"\"" | ~["\n","\r","\""])* "\"" | ("`" ("``" | ~["\n","\r","`"])* "`") | ( "[" (~["\n","\r","]"])* "]" ) >
3269-
{
3253+
int end = closingSingleQuote(matchedToken.image, backslashEscapes);
3254+
if (end > 0 && end < matchedToken.image.length()) {
3255+
matchedToken.image = image.substring(0, end);
3256+
input_stream.backup(image.length() - end);
3257+
}
3258+
}
3259+
| < S_QUOTED_IDENTIFIER: "\"" ( "\"\"" | ~["\n","\r","\""])* "\""
3260+
// MySQL double-quoted string with backslash escapes; trimmed below when not enabled
3261+
| "\"" ( "\"\"" | "\\" ~["\n","\r"] | ~["\n","\r","\"","\\"])* "\""
3262+
| ("`" ("``" | ~["\n","\r","`"])* "`") | ( "[" (~["\n","\r","]"])* "]" ) >
3263+
{
3264+
if ( matchedToken.image.charAt(0) == '"' ) {
3265+
// Both alternatives above compete for the longest match. Keep the closing quote of
3266+
// the active quoting rules: a backslash escapes the next character only in
3267+
// double-quoted strings with backslash escapes, as in MySQL.
3268+
int end = closingQuote(matchedToken.image, 0, '"',
3269+
configuration.getAsBoolean(Feature.allowDoubleQuotedStrings)
3270+
&& configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter));
3271+
if (end > 0 && end < matchedToken.image.length()) {
3272+
matchedToken.image = image.substring(0, end);
3273+
input_stream.backup(image.length() - end);
3274+
}
3275+
}
32703276
if ( !configuration.getAsBoolean(Feature.allowSquareBracketQuotation)
32713277
&& matchedToken.image.charAt(0) == '[' ) {
32723278

‎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)