From 1455e47c1d87d9e57108c0aef545d8abd5836f7b Mon Sep 17 00:00:00 2001 From: minleejae Date: Tue, 13 Oct 2026 13:38:52 +0900 Subject: [PATCH] fix: resolve backslash escapes in StringValue.getNotExcapedValue() getNotExcapedValue() only collapsed doubled single quotes. With MySQL backslash escapes it returned 'it\'s' as it\'s and 'a\\b' as a\\b, PostgreSQL E'it\'s' kept its backslash, and "it""s" kept both quotes. Record at parse time how a literal's backslashes were read: MySQL and MariaDB backslash escapes, including the default configuration with backslash escapes enabled, PostgreSQL escape strings, or standard SQL. getNotExcapedValue() resolves the literal's own doubled quote and these escapes; PostgreSQL octal and hexadecimal escapes are decoded as UTF-8 bytes. Constructed values and other dialects keep the standard reading. Signed-off-by: minleejae --- .../sf/jsqlparser/expression/StringValue.java | 189 +++++++++++++++++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 150 ++++++++------ src/site/sphinx/usage.rst | 20 ++ .../expression/StringValueTest.java | 120 +++++++++++ 4 files changed, 405 insertions(+), 74 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/StringValue.java b/src/main/java/net/sf/jsqlparser/expression/StringValue.java index e959ad292..0885154a5 100644 --- a/src/main/java/net/sf/jsqlparser/expression/StringValue.java +++ b/src/main/java/net/sf/jsqlparser/expression/StringValue.java @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.expression; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -22,9 +24,24 @@ public final class StringValue extends ASTNodeAccessImpl implements Expression { public static final List ALLOWED_PREFIXES = Arrays.asList("N", "U", "E", "R", "B", "RB", "_utf8", "Q"); + + /** + * How a backslash in the literal body was read. The parser records the escaping of the + * configuration that lexed the literal; constructed values default to {@link #STANDARD}. + */ + public enum Escaping { + /** Only a doubled quote character is an escape, as in standard SQL. */ + STANDARD, + /** MySQL/MariaDB backslash escapes, for example {@code \n}, {@code \'} and {@code \\}. */ + MYSQL_BACKSLASH, + /** PostgreSQL escape string escapes, for example {@code E'\n'} and {@code E'\x41'}. */ + POSTGRESQL_ESCAPE_STRING + } + private String value = ""; private String prefix = null; private String quoteStr = "'"; + private Escaping escaping = Escaping.STANDARD; public StringValue() { // empty constructor @@ -119,19 +136,175 @@ public StringValue setQuoteStr(String quoteStr) { return this; } + public Escaping getEscaping() { + return escaping; + } + + public void setEscaping(Escaping escaping) { + this.escaping = escaping == null ? Escaping.STANDARD : escaping; + } + + public StringValue withEscaping(Escaping escaping) { + setEscaping(escaping); + return this; + } + + /** + * Returns the literal's characters with its escapes resolved: a doubled quote character and, + * depending on {@link #getEscaping()}, backslash escape sequences. PostgreSQL octal and + * hexadecimal escapes denote bytes, which are decoded as UTF-8. Dollar-quoted bodies are + * returned unchanged. + */ public String getNotExcapedValue() { if (quoteStr != null && quoteStr.startsWith("$")) { return value; } - StringBuilder buffer = new StringBuilder(value.length()); - int from = 0; - int index; - while ((index = value.indexOf("''", from)) != -1) { - buffer.append(value, from, index + 1); - from = index + 2; + char quote = "\"".equals(quoteStr) ? '"' : '\''; + Unescaped result = new Unescaped(value.length()); + int i = 0; + while (i < value.length()) { + char c = value.charAt(i); + if (c == '\\' && escaping != Escaping.STANDARD && i + 1 < value.length()) { + i = escaping == Escaping.MYSQL_BACKSLASH + ? appendMySqlEscape(result, value.charAt(i + 1), i + 1) + : appendPostgresEscape(result, value, i + 1); + } else if (c == quote && i + 1 < value.length() && value.charAt(i + 1) == quote) { + result.append(quote); + i += 2; + } else { + result.append(c); + i++; + } + } + return result.toString(); + } + + /** Appends the MySQL escape {@code c} at {@code index} and returns the index after it. */ + private static int appendMySqlEscape(Unescaped result, char c, int index) { + switch (c) { + case '0': + result.append('\0'); + break; + case 'b': + result.append('\b'); + break; + case 'n': + result.append('\n'); + break; + case 'r': + result.append('\r'); + break; + case 't': + result.append('\t'); + break; + case 'Z': + result.append((char) 0x1A); + break; + case '%': + case '_': + // kept with the backslash, so that LIKE patterns still match them literally + result.append('\\'); + result.append(c); + break; + default: + result.append(c); + } + return index + 1; + } + + /** Appends the PostgreSQL escape string escape at {@code index}; returns the next index. */ + private static int appendPostgresEscape(Unescaped result, String text, int index) { + char c = text.charAt(index); + switch (c) { + case 'b': + result.append('\b'); + return index + 1; + case 'f': + result.append('\f'); + return index + 1; + case 'n': + result.append('\n'); + return index + 1; + case 'r': + result.append('\r'); + return index + 1; + case 't': + result.append('\t'); + return index + 1; + case 'x': + return appendNumber(result, text, index + 1, 16, 1, 2, c, true); + case 'u': + return appendNumber(result, text, index + 1, 16, 4, 4, c, false); + case 'U': + return appendNumber(result, text, index + 1, 16, 8, 8, c, false); + default: + if (c >= '0' && c <= '7') { + return appendNumber(result, text, index, 8, 1, 3, c, true); + } + result.append(c); + return index + 1; + } + } + + /** + * Appends the byte or code point spelled by {@code min} to {@code max} digits at {@code start}, + * or {@code fallback} when fewer digits follow, and returns the next index. + */ + private static int appendNumber(Unescaped result, String text, int start, int radix, + int min, int max, char fallback, boolean isByte) { + int end = start; + while (end < text.length() && end - start < max + && Character.digit(text.charAt(end), radix) >= 0) { + end++; + } + if (end - start < min) { + result.append(fallback); + return start; + } + int number = Integer.parseInt(text.substring(start, end), radix); + if (isByte) { + result.appendByte(number); + } else { + result.appendCodePoint(number); + } + return end; + } + + /** Collects unescaped characters; consecutive escaped bytes are decoded as UTF-8. */ + private static final class Unescaped { + private final StringBuilder chars; + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + Unescaped(int capacity) { + chars = new StringBuilder(capacity); + } + + void append(char c) { + flushBytes(); + chars.append(c); + } + + void appendCodePoint(int codePoint) { + flushBytes(); + chars.appendCodePoint(codePoint); + } + + void appendByte(int value) { + bytes.write(value); + } + + private void flushBytes() { + if (bytes.size() > 0) { + chars.append(new String(bytes.toByteArray(), StandardCharsets.UTF_8)); + bytes.reset(); + } + } + + @Override + public String toString() { + flushBytes(); + return chars.toString(); } - buffer.append(value, from, value.length()); - return buffer.toString(); } @Override diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index eb904885a..c500a8d34 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2145,6 +2145,24 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** Creates a string literal that remembers how this configuration lexed its backslashes. */ + private StringValue stringValue(Token token) { + StringValue value = new StringValue(token.image); + if (value.getQuoteStr() == null || value.getQuoteStr().startsWith("$")) { + return value; + } + String dialect = getAsString(Feature.dialect); + boolean backslashes = getAsBoolean(Feature.allowBackslashEscapeCharacter); + if (Dialect.POSTGRESQL.name().equals(dialect)) { + if (backslashes || "E".equalsIgnoreCase(value.getPrefix())) { + value.setEscaping(StringValue.Escaping.POSTGRESQL_ESCAPE_STRING); + } + } else if (backslashes && (dialect == null || isMySqlDialect())) { + value.setEscaping(StringValue.Escaping.MYSQL_BACKSLASH); + } + return value; + } + /** * Semantic lookahead for the adjacent string literal concatenation in PrimaryExpression: * true when Feature.adjacentStringLiterals is on and the next token is a string literal @@ -3625,7 +3643,7 @@ DoStatement DoStatement(): [ LOOKAHEAD({ isKeywordAhead("LANGUAGE") }) AccessKeyword("LANGUAGE") language=RelObjectName() { statement.setLanguage(language); } ] ) - { statement.setCode(new StringValue(code.image)); return statement; } + { statement.setCode(stringValue(code)); return statement; } } Block Block() #Block : { @@ -4085,7 +4103,7 @@ DBMSTableDestinationOption DBMSTableDestinationOption(): { token = | token = ) { dbmsTableDestinationOption = new DBMSTableDestinationOption(token.image); } - | token = token2 = token3 = { dbmsTableDestinationOption = new DBMSTableDestinationOption(token.image + " " + token2.image, new StringValue(token3.image)); } + | token = token2 = token3 = { dbmsTableDestinationOption = new DBMSTableDestinationOption(token.image + " " + token2.image, stringValue(token3)); } ) { @@ -4159,7 +4177,7 @@ StringValue ImportExportStatement() #ImportExportStatement: { } { token = { - statement = new StringValue(token.image); + statement = stringValue(token); linkAST(statement, jjtThis); return statement; } @@ -4181,7 +4199,7 @@ StringValue File() #File: { } { token = { - StringValue file = new StringValue(token.image); + StringValue file = stringValue(token); linkAST(file, jjtThis); return file; } @@ -4228,7 +4246,7 @@ CSVColumn CSVDestinationColumn(): { LOOKAHEAD(2) token= ".." token2= { csvColumn = new CSVColumn(Long.valueOf(token.image), Long.valueOf(token2.image)); } | token= { csvColumn = new CSVColumn(Long.valueOf(token.image)); } - [ "=" token = { csvColumn.setFormat(new StringValue(token.image)); }] + [ "=" token = { csvColumn.setFormat(stringValue(token)); }] [ "=" @@ -4266,7 +4284,7 @@ CSVColumn CSVSourceColumn(): { LOOKAHEAD(2) token= ".." token2= { csvColumn = new CSVColumn(Long.valueOf(token.image), Long.valueOf(token2.image)); } | token= { csvColumn = new CSVColumn(Long.valueOf(token.image)); } - [ "=" token = { csvColumn.setFormat(new StringValue(token.image)); }] + [ "=" token = { csvColumn.setFormat(stringValue(token)); }] ) { return csvColumn; @@ -4292,7 +4310,7 @@ FBVColumn FBVDestinationColumn(): { } { ( token= "=" token2= { fbvColumn = new FBVColumn(token.image, new LongValue(token2.image)); } - | ( token= | token= ) "=" token2= { fbvColumn = new FBVColumn(token.image, new StringValue(token2.image)); } + | ( token= | token= ) "=" token2= { fbvColumn = new FBVColumn(token.image, stringValue(token2)); } | token= "=" ( token2= | token2= ) { fbvColumn = new FBVColumn(token.image, token2.image); } ) { @@ -4324,7 +4342,7 @@ FBVColumn FBVSourceColumn(): { } { ( ( token= | token= ) "=" token2= { fbvColumn = new FBVColumn(token.image, new LongValue(token2.image)); } - | ( token= | token= ) "=" token2= { fbvColumn = new FBVColumn(token.image, new StringValue(token2.image)); } + | ( token= | token= ) "=" token2= { fbvColumn = new FBVColumn(token.image, stringValue(token2)); } | token= "=" ( token2= | token2= ) { fbvColumn = new FBVColumn(token.image, token2.image); } ) { @@ -4358,12 +4376,12 @@ FileOption FileDestinationOption(): { ( ( token= | token= ) { fileOption = new FileOption(token.image); } | token= token2= token3= { fileOption = new FileOption(token.image + " " + token2.image + " " + token3.image); } - | ( token= | token= | token= ) "=" token2= { fileOption = new FileOption(token.image, new StringValue(token2.image)); } + | ( token= | token= | token= ) "=" token2= { fileOption = new FileOption(token.image, stringValue(token2)); } | ( token= token2= "=" token3= | token= ( token2= | token2= ) "=" token3= ) - { fileOption = new FileOption(token.image + " " + token2.image, new StringValue(token3.image)); } + { fileOption = new FileOption(token.image + " " + token2.image, stringValue(token3)); } | token= "=" ( token2= | token2= | token2= ) { fileOption = new FileOption(token.image, token2.image); } ) { @@ -4391,7 +4409,7 @@ FileOption FileSourceOption(): { ( ( token= | token= | token= ) { fileOption = new FileOption(token.image); } | ( - ( token= | token= ) "=" token2= { fileOption = new FileOption(token.image, new StringValue(token2.image)); } + ( token= | token= ) "=" token2= { fileOption = new FileOption(token.image, stringValue(token2)); } | token= "=" token2= { fileOption = new FileOption(token.image, new LongValue(token2.image)); } ) | LOOKAHEAD(2) @@ -4399,7 +4417,7 @@ FileOption FileSourceOption(): { token= token2= "=" token3= | token= ( token2= | token2= ) "=" token3= ) - { fileOption = new FileOption(token.image + " " + token2.image, new StringValue(token3.image)); } + { fileOption = new FileOption(token.image + " " + token2.image, stringValue(token3)); } | token= token2= "=" token3= { fileOption = new FileOption(token.image + " " + token2.image, new LongValue(token3.image)); } ) @@ -4519,10 +4537,10 @@ CertificateVerification CertificateVerification(): { [ LOOKAHEAD(2) - token = { certificateVerification.setPublicKey(new StringValue(token.image)); } + token = { certificateVerification.setPublicKey(stringValue(token)); } ] | - token = { certificateVerification.setPublicKey(new StringValue(token.image)); } + token = { certificateVerification.setPublicKey(stringValue(token)); } ) { return certificateVerification; @@ -4555,7 +4573,7 @@ ScriptSourceDestination ScriptSourceDestination(): { ( LOOKAHEAD(2) - property = RelObjectName() "=" token = { value = new StringValue(token.image); } + property = RelObjectName() "=" token = { value = stringValue(token); } { properties.add(property); values.add(value); @@ -4573,9 +4591,9 @@ UserIdentification UserIdentification(): { Token token; } { - token= { userIdentification.setUser(new StringValue(token.image)); } + token= { userIdentification.setUser(stringValue(token)); } - token= { userIdentification.setPassword(new StringValue(token.image)); } + token= { userIdentification.setPassword(stringValue(token)); } { return userIdentification; @@ -4593,7 +4611,7 @@ ConnectionDefinition ConnectionDefinition(): { ( connectionObjectName = RelObjectName() { connectionDefinition.setConnectionObjectName(connectionObjectName); } - | token= { connectionDefinition.setConnectionDefinition(new StringValue(token.image)); } + | token= { connectionDefinition.setConnectionDefinition(stringValue(token)); } ) [ LOOKAHEAD(2) userIdentification = UserIdentification() { connectionDefinition.setUserIdentification(userIdentification); } ] @@ -4621,7 +4639,7 @@ ConnectionDefinition CloudConnectionDefinition(): { ( connectionObjectName = RelObjectName() { connectionDefinition.setConnectionObjectName(connectionObjectName); } - | token = { connectionDefinition.setConnectionDefinition(new StringValue(token.image)); } + | token = { connectionDefinition.setConnectionDefinition(stringValue(token)); } ) [ userIdentification = UserIdentification() { connectionDefinition.setUserIdentification(userIdentification); } ] @@ -4963,7 +4981,7 @@ void SetCharacterSet(SetStatement set): { ( { name = "CHARACTER SET"; } | { name = "CHARSET"; } ) - ( value= { expression = new StringValue(value.image); } + ( value= { expression = stringValue(value); } | (value= | value= | value=) { expression = new Column(value.image); } ) { set.add(name, new ExpressionList(expression), false); } @@ -5430,7 +5448,7 @@ AssertStatement AssertStatement() #AssertStatement: { expression=Expression() { assertStatement.setExpression(expression); } [ token= - { assertStatement.setDescription(new StringValue(token.image)); } ] + { assertStatement.setDescription(stringValue(token)); } ] { return assertStatement; } } @@ -8482,7 +8500,7 @@ ColumnsTransformer ColumnsTransformer(): ( exceptColumns = ParenthesedColumnList() | - tk= { exceptPattern = new StringValue(tk.image); } + tk= { exceptPattern = stringValue(tk); } ) { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) .setStrict(strict); @@ -8895,11 +8913,11 @@ MySqlSelectIntoClause MySqlSelectIntoClause(MySqlSelectIntoClause.Position posit ( { intoClause.setType(MySqlSelectIntoClause.Type.OUTFILE); } - token= { intoClause.setFileName(new StringValue(token.image)); } + token= { intoClause.setFileName(stringValue(token)); } MySqlSelectIntoOutfileTail(intoClause) | { intoClause.setType(MySqlSelectIntoClause.Type.DUMPFILE); } - token= { intoClause.setFileName(new StringValue(token.image)); } + token= { intoClause.setFileName(stringValue(token)); } | LOOKAHEAD({ isMySqlDialect() }) variable=MySqlIntoVariable() { variables.add(variable); } @@ -8996,20 +9014,20 @@ void MySqlSelectIntoFieldsClause(MySqlSelectIntoClause intoClause): ( LOOKAHEAD( ) token= - { intoClause.setFieldsTerminatedBy(new StringValue(token.image)); } + { intoClause.setFieldsTerminatedBy(stringValue(token)); } ( LOOKAHEAD(( | )) [ { intoClause.setFieldsOptionallyEnclosed(true); } ] token= - { intoClause.setFieldsEnclosedBy(new StringValue(token.image)); } + { intoClause.setFieldsEnclosedBy(stringValue(token)); } [ LOOKAHEAD( ) token= - { intoClause.setFieldsEscapedBy(new StringValue(token.image)); } + { intoClause.setFieldsEscapedBy(stringValue(token)); } ] | LOOKAHEAD( ) token= - { intoClause.setFieldsEscapedBy(new StringValue(token.image)); } + { intoClause.setFieldsEscapedBy(stringValue(token)); } | { } ) @@ -9017,15 +9035,15 @@ void MySqlSelectIntoFieldsClause(MySqlSelectIntoClause intoClause): LOOKAHEAD(( | )) [ { intoClause.setFieldsOptionallyEnclosed(true); } ] token= - { intoClause.setFieldsEnclosedBy(new StringValue(token.image)); } + { intoClause.setFieldsEnclosedBy(stringValue(token)); } [ LOOKAHEAD( ) token= - { intoClause.setFieldsEscapedBy(new StringValue(token.image)); } + { intoClause.setFieldsEscapedBy(stringValue(token)); } ] | LOOKAHEAD( ) token= - { intoClause.setFieldsEscapedBy(new StringValue(token.image)); } + { intoClause.setFieldsEscapedBy(stringValue(token)); } | { } ) @@ -9040,15 +9058,15 @@ void MySqlSelectIntoLinesClause(MySqlSelectIntoClause intoClause): ( LOOKAHEAD( ) token= - { intoClause.setLinesStartingBy(new StringValue(token.image)); } + { intoClause.setLinesStartingBy(stringValue(token)); } [ LOOKAHEAD( ) token= - { intoClause.setLinesTerminatedBy(new StringValue(token.image)); } + { intoClause.setLinesTerminatedBy(stringValue(token)); } ] | LOOKAHEAD( ) token= - { intoClause.setLinesTerminatedBy(new StringValue(token.image)); } + { intoClause.setLinesTerminatedBy(stringValue(token)); } | { } ) @@ -10636,7 +10654,7 @@ Expression InExpression(Expression leftExpression) #InExpression : [ { usingNot=true; } ] ( - LOOKAHEAD(2) token= { rightExpression = new StringValue(token.image); } + LOOKAHEAD(2) token= { rightExpression = stringValue(token); } | LOOKAHEAD({ isNestedSetOperationAhead() }) rightExpression = ParenthesedSelect() | @@ -10781,7 +10799,7 @@ Expression LikeExpression(Expression leftExpression) #LikeExpression: ] [ LOOKAHEAD(2) ( - LOOKAHEAD(2) token = { result.setEscape( new StringValue( token.image ) ); } + LOOKAHEAD(2) token = { result.setEscape( stringValue(token) ); } | escape=Expression() { result.setEscape(escape); } ) @@ -10812,7 +10830,7 @@ Expression SimilarToExpression(Expression leftExpression) #SimilarToExpression: } } ] - [ LOOKAHEAD(2) token= { result.setEscape((new StringValue(token.image)).getValue()); }] + [ LOOKAHEAD(2) token= { result.setEscape((stringValue(token)).getValue()); }] { result.setLeftExpression(leftExpression); result.setRightExpression(rightExpression); @@ -11191,7 +11209,7 @@ StringValue CharacterSetIntroducedString(): { introducer= literal= { - return new StringValue(literal.image).withPrefix(introducer.image); + return stringValue(literal).withPrefix(introducer.image); } } @@ -11328,10 +11346,10 @@ StringValue CharacterLiteral(): StringValue value; } { - literal= { value = new StringValue(literal.image); } + literal= { value = stringValue(literal); } ( LOOKAHEAD({ isAdjacentStringConcat() }) literal= - { value.setValue(value.getValue() + new StringValue(literal.image).getValue()); } + { value.setValue(value.getValue() + stringValue(literal).getValue()); } )* { return value; } } @@ -13581,8 +13599,8 @@ XMLSerializeExpr XMLSerializeExpr(): { { result.setSerializationMode(XMLSerializeExpr.SerializationMode.valueOf(token.image.toUpperCase(Locale.ROOT))); } expression=Expression() { result.setExpression(expression); } [ dataType=ColDataType() { result.setDataType(dataType); } ] - [ token= { result.setEncoding(new StringValue(token.image)); } ] - [ token= { result.setVersion(new StringValue(token.image)); } ] + [ token= { result.setEncoding(stringValue(token)); } ] + [ token= { result.setVersion(stringValue(token)); } ] [ LOOKAHEAD(2) token= { requireDdlSyntax("INDENT".equalsIgnoreCase(token.image), "Expected INDENT after NO"); @@ -14636,7 +14654,7 @@ ColumnOption ColumnDefinitionOption(): { { option = ColumnOption.collate(collation); } | LOOKAHEAD() tk= - { option = ColumnOption.comment(new StringValue(tk.image)); } + { option = ColumnOption.comment(stringValue(tk)); } | LOOKAHEAD( ) defaultExpression=Expression() { option = ColumnOption.onUpdate(defaultExpression); } @@ -14774,7 +14792,7 @@ StringValue MySqlAccountNamePart(): } { ( - token= { value = new StringValue(token.image); } + token= { value = stringValue(token); } | name=RelObjectName() { value = new StringValue(name).setQuoteStr(""); } ) @@ -14794,7 +14812,7 @@ UserAuthentication MySqlUserAuthentication(): ( credential= { authentication.setMode(UserAuthentication.Mode.BY_PASSWORD); - authentication.setCredential(new StringValue(credential.image)); + authentication.setCredential(stringValue(credential)); } | { @@ -14811,7 +14829,7 @@ UserAuthentication MySqlUserAuthentication(): ( credential= { authentication.setMode(UserAuthentication.Mode.WITH_PLUGIN_BY_PASSWORD); - authentication.setCredential(new StringValue(credential.image)); + authentication.setCredential(stringValue(credential)); } | { @@ -14822,7 +14840,7 @@ UserAuthentication MySqlUserAuthentication(): | credential= { authentication.setMode(UserAuthentication.Mode.WITH_PLUGIN_AS_STRING); - authentication.setCredential(new StringValue(credential.image)); + authentication.setCredential(stringValue(credential)); } ] ) @@ -14895,7 +14913,7 @@ Expression MySqlEventIntervalValue(): | value= { expression = new DoubleValue(value.image); } | - value= { expression = new StringValue(value.image); } + value= { expression = stringValue(value); } ) { return expression; } } @@ -14946,7 +14964,7 @@ CreateEvent CreateEvent(): [ LOOKAHEAD({ getToken(1).kind == K_ENABLE || getToken(1).kind == K_DISABLE }) status=MySqlEventStatus() { createEvent.setStatus(status); } ] [ LOOKAHEAD({ getToken(1).kind == K_COMMENT }) comment= { - createEvent.setComment(new StringValue(comment.image)); + createEvent.setComment(stringValue(comment)); } ] body=SingleStatement() { createEvent.setBody(body); } { return createEvent; } @@ -14974,7 +14992,7 @@ AlterEvent AlterEvent(): [ LOOKAHEAD({ getToken(1).kind == K_ENABLE || getToken(1).kind == K_DISABLE }) status=MySqlEventStatus() { alterEvent.setStatus(status); } ] [ LOOKAHEAD({ getToken(1).kind == K_COMMENT }) comment= { - alterEvent.setComment(new StringValue(comment.image)); + alterEvent.setComment(stringValue(comment)); } ] [ LOOKAHEAD({ getToken(1).kind == K_DO }) body=SingleStatement() { alterEvent.setBody(body); } ] @@ -15811,7 +15829,7 @@ ForeignDataOption ForeignDataOption(): [ LOOKAHEAD(1) ( { action = ForeignDataOption.Action.ADD; } | { action = ForeignDataOption.Action.SET; } ) ] name=RelObjectName() value= - { option = new ForeignDataOption(name, new StringValue(value.image)); option.setAction(action); } + { option = new ForeignDataOption(name, stringValue(value)); option.setAction(action); } ) { return option; } } @@ -19583,12 +19601,12 @@ RoleOption RoleOption(boolean create): | LOOKAHEAD({ isAccessKeywordAhead("PASSWORD") || isAccessKeywordAhead("ENCRYPTED") }) [ AccessKeyword("ENCRYPTED") { encrypted = true; } ] - ( value= { expression = new StringValue(value.image); } | { expression = new NullValue(); } ) + ( value= { expression = stringValue(value); } | { expression = new NullValue(); } ) { option = new RoleOption(RoleOption.Kind.PASSWORD); option.setEncrypted(encrypted); option.setValue(expression); } | LOOKAHEAD({ isAccessKeywordAhead("VALID") }) AccessKeyword("VALID") AccessKeyword("UNTIL") value= - { option = new RoleOption(RoleOption.Kind.VALID_UNTIL); option.setValue(new StringValue(value.image)); } + { option = new RoleOption(RoleOption.Kind.VALID_UNTIL); option.setValue(stringValue(value)); } | ( AccessKeyword("ROLE") { option = new RoleOption(RoleOption.Kind.IN_ROLE); } @@ -19924,7 +19942,7 @@ Expression PostgreSqlTriggerArgument(): { Expression result; Token token; String name; } { ( - token= { result = new StringValue(token.image); } + token= { result = stringValue(token); } | token= { result = new LongValue(token.image); } | token= { result = new DoubleValue(token.image); } | name=RelObjectName() { result = createColumn(name); } @@ -20390,7 +20408,7 @@ void TypeDdlKeyword(String expected): Expression ExtensionVersion(): { Token token; String name; Expression result; } { - ( token= { result = new StringValue(token.image); } + ( token= { result = stringValue(token); } | name=RelObjectName() { result = createColumn(name); } ) { return result; } } @@ -20419,8 +20437,8 @@ CreateType CreateType(): ( LOOKAHEAD({ isKeywordAhead("ENUM") }) TypeDdlKeyword("ENUM") "(" - [ label= { enumeration.getLabels().add(new StringValue(label.image)); } - ( "," label= { enumeration.getLabels().add(new StringValue(label.image)); } )* ] + [ label= { enumeration.getLabels().add(stringValue(label)); } + ( "," label= { enumeration.getLabels().add(stringValue(label)); } )* ] ")" { result.setDefinition(enumeration); } | "(" option=RangeTypeOption() { range.getOptions().add(option); } ( "," option=RangeTypeOption() { range.getOptions().add(option); } )* ")" @@ -20488,14 +20506,14 @@ AlterType AlterType(): LOOKAHEAD( ) { result.setAction(AlterType.Action.ADD_VALUE); } [ { result.setIfNotExists(true); } ] - label= { result.setValue(new StringValue(label.image)); } + label= { result.setValue(stringValue(label)); } [ ( { result.setPosition(AlterType.Position.BEFORE); } | TypeDdlKeyword("AFTER") { result.setPosition(AlterType.Position.AFTER); } ) - label= { result.setNeighborValue(new StringValue(label.image)); } ] + label= { result.setNeighborValue(stringValue(label)); } ] | ( - label= { result.setValue(new StringValue(label.image)); } - label= { result.setNewValue(new StringValue(label.image)); result.setAction(AlterType.Action.RENAME_VALUE); } + label= { result.setValue(stringValue(label)); } + label= { result.setNewValue(stringValue(label)); result.setAction(AlterType.Action.RENAME_VALUE); } | LOOKAHEAD({ isKeywordAhead("ATTRIBUTE") }) TypeDdlKeyword("ATTRIBUTE") name=RelObjectName() { result.setAttributeName(name); } name=RelObjectName() { result.setNewName(name); result.setAction(AlterType.Action.RENAME_ATTRIBUTE); } @@ -20873,7 +20891,7 @@ CreateSubscription CreateSubscription(): { CreateSubscription result = new CreateSubscription(); String name; Token connection; List publications; List options; } { ReplicationKeyword("SUBSCRIPTION") name=RelObjectName() { result.setName(name); } - ReplicationKeyword("CONNECTION") connection= { result.setConnection(new StringValue(connection.image)); } + ReplicationKeyword("CONNECTION") connection= { result.setConnection(stringValue(connection)); } ReplicationKeyword("PUBLICATION") publications=SubscriptionPublications() { result.setPublications(publications); } [ LOOKAHEAD(2) options=SubscriptionOptions() { result.setOptions(options); } ] { return result; } @@ -20885,7 +20903,7 @@ AlterSubscription AlterSubscription(): ReplicationKeyword("SUBSCRIPTION") name=RelObjectName() { result.setName(name); } ( LOOKAHEAD({ isKeywordAhead("CONNECTION") }) ReplicationKeyword("CONNECTION") - connection= { result.setConnection(new StringValue(connection.image)); result.setAction(AlterSubscription.Action.CONNECTION); } + connection= { result.setConnection(stringValue(connection)); result.setAction(AlterSubscription.Action.CONNECTION); } | LOOKAHEAD({ isKeywordAhead("OWNER") }) ReplicationKeyword("OWNER") name=RelObjectName() { result.setNewName(name); result.setAction(AlterSubscription.Action.OWNER); } | name=RelObjectName() { result.setNewName(name); result.setAction(AlterSubscription.Action.RENAME); } @@ -21517,7 +21535,7 @@ CreateServer CreateServer(): ContextualKeyword("SERVER") [ LOOKAHEAD(2) { statement.setIfNotExists(true); } ] name=RelObjectName() { statement.setName(name); } - [ type= { statement.setType(new StringValue(type.image)); } ] + [ type= { statement.setType(stringValue(type)); } ] [ version=ForeignServerVersion() { statement.setVersion(version); } ] ContextualKeyword("WRAPPER") name=RelObjectName() { statement.setForeignDataWrapper(name); } [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) options=ForeignDataOptions(false) { statement.setOptions(options); } ] @@ -21527,7 +21545,7 @@ CreateServer CreateServer(): Expression ForeignServerVersion(): { Token value; Expression version; } { - ( value= { version = new StringValue(value.image); } + ( value= { version = stringValue(value); } | { version = new NullValue(); } ) { return version; } } @@ -21702,7 +21720,7 @@ NotifyStatement NotifyStatement(): { NotifyStatement statement = new NotifyStatement(); String channel; Token payload; } { channel=RelObjectName() { statement.setChannel(channel); } - [ "," payload= { statement.setPayload(new StringValue(payload.image)); } ] + [ "," payload= { statement.setPayload(stringValue(payload)); } ] { return statement; } } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 1d204c6e9..b51f44cc7 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1366,6 +1366,26 @@ responsibility. When constructing a ``ColDataType`` directly, use ``null`` for omitted parameters. The legacy ``int`` constructor continues to treat negative arguments as omitted parameters. +String literal values +~~~~~~~~~~~~~~~~~~~~~ + +``StringValue.getValue()`` returns the literal body as written, and ``toString()`` +renders it unchanged. ``getNotExcapedValue()`` returns the characters it denotes. +A doubled quote of the literal's own quote character is resolved, so +``"it""s"`` yields ``it"s``. ``getEscaping()`` records how the parser read +backslashes: + +* ``MYSQL_BACKSLASH`` without a dialect or with ``Dialect.MYSQL``/``MARIADB`` when + backslash escapes are enabled: ``'it\'s'`` yields ``it's``, ``\n``, ``\t``, + ``\0`` and ``\Z`` become their characters, and ``\%`` and ``\_`` keep their backslash. +* ``POSTGRESQL_ESCAPE_STRING`` for ``E'...'`` with ``Dialect.POSTGRESQL``, including + ``\x``, octal and ``\u`` escapes; octal and hexadecimal escapes are UTF-8 bytes. +* ``STANDARD`` otherwise, including dollar-quoted strings and constructed values; + backslashes stay literal. Other dialects with backslash escapes keep this reading. + +``setEscaping()`` selects the reading for a constructed value. Escaping is not +part of ``equals()``, like the quote character. + Other dialect-specific syntax ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/test/java/net/sf/jsqlparser/expression/StringValueTest.java b/src/test/java/net/sf/jsqlparser/expression/StringValueTest.java index 7ea053d62..420748c5a 100644 --- a/src/test/java/net/sf/jsqlparser/expression/StringValueTest.java +++ b/src/test/java/net/sf/jsqlparser/expression/StringValueTest.java @@ -14,6 +14,10 @@ import java.time.Duration; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.create.table.ColumnOption; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; @@ -74,6 +78,122 @@ public void testGetNotExcapedValueLargeInputIsLinear() { } } + private static StringValue literal(String sql, Dialect dialect) throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT " + sql, + parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }); + return (StringValue) select.getSelectItem(0).getExpression(); + } + + @Test + void testMySqlBackslashEscapes() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB}) { + StringValue value = literal("'it\\'s a\\\\b'", dialect); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, value.getEscaping()); + assertEquals("it\\'s a\\\\b", value.getValue()); + assertEquals("it's a\\b", value.getNotExcapedValue()); + assertEquals("it's", literal("'it''s'", dialect).getNotExcapedValue()); + } + assertEquals("x\ny\tz\0\u001A\b\r", + literal("'x\\ny\\tz\\0\\Z\\b\\r'", Dialect.MYSQL).getNotExcapedValue()); + // \% and \_ keep their backslash; other escaped characters stand for themselves + assertEquals("a\\%b\\_cq\"", literal("'a\\%b\\_c\\q\\\"'", Dialect.MYSQL) + .getNotExcapedValue()); + assertEquals("it's", literal("N'it\\'s'", Dialect.MYSQL).getNotExcapedValue()); + } + + @Test + void testDoubledQuoteFollowsTheQuoteCharacter() throws JSQLParserException { + assertEquals("it\"s", literal("\"it\"\"s\"", Dialect.MYSQL).getNotExcapedValue()); + assertEquals("it''s", literal("\"it''s\"", Dialect.MYSQL).getNotExcapedValue()); + } + + @Test + void testBackslashEscapesWithoutDialect() throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT 'it\\'s'", + parser -> parser.withBackslashEscapeCharacter(true)); + StringValue value = (StringValue) select.getSelectItem(0).getExpression(); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, value.getEscaping()); + assertEquals("it's", value.getNotExcapedValue()); + + StringValue standard = literal("'a\\nb'", null); + assertEquals(StringValue.Escaping.STANDARD, standard.getEscaping()); + assertEquals("a\\nb", standard.getNotExcapedValue()); + } + + @Test + void testPostgresEscapeStrings() throws JSQLParserException { + StringValue standard = literal("'a\\nb'", Dialect.POSTGRESQL); + assertEquals(StringValue.Escaping.STANDARD, standard.getEscaping()); + assertEquals("a\\nb", standard.getNotExcapedValue()); + + StringValue escape = literal("E'it\\'s\\tx\\\\'", Dialect.POSTGRESQL); + assertEquals(StringValue.Escaping.POSTGRESQL_ESCAPE_STRING, escape.getEscaping()); + assertEquals("it's\tx\\", escape.getNotExcapedValue()); + assertEquals("it's", literal("E'it''s'", Dialect.POSTGRESQL).getNotExcapedValue()); + // octal and hexadecimal escapes are bytes of the UTF-8 encoding + assertEquals("AAéé café 😀 q", literal( + "E'\\x41\\101\\303\\251\\xC3\\xA9 caf\\u00e9 \\U0001F600 \\q'", + Dialect.POSTGRESQL).getNotExcapedValue()); + assertEquals("it''s\\n", literal("$$it''s\\n$$", Dialect.POSTGRESQL) + .getNotExcapedValue()); + } + + @Test + void testOtherBackslashDialectsKeepTheirSource() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.BIGQUERY, Dialect.DATABRICKS, + Dialect.SNOWFLAKE}) { + StringValue value = literal("'it\\'s'", dialect); + assertEquals(StringValue.Escaping.STANDARD, value.getEscaping()); + assertEquals("it\\'s", value.getNotExcapedValue()); + } + } + + @Test + void testConstructedValuesDefaultToStandardEscaping() { + StringValue value = new StringValue("'a\\n''b'"); + assertEquals(StringValue.Escaping.STANDARD, value.getEscaping()); + assertEquals("a\\n'b", value.getNotExcapedValue()); + assertEquals("a\n'b", + value.withEscaping(StringValue.Escaping.MYSQL_BACKSLASH).getNotExcapedValue()); + value.setEscaping(null); + assertEquals(StringValue.Escaping.STANDARD, value.getEscaping()); + } + + @Test + void testColumnDefaultValueIsUnescaped() throws JSQLParserException { + for (String literal : new String[] {"'it''s'", "'it\\'s'"}) { + Alter alter = (Alter) CCJSqlParserUtil.parse( + "ALTER TABLE t ADD COLUMN s VARCHAR(10) DEFAULT " + literal, + parser -> parser.withDialect(Dialect.MYSQL)); + ColumnOption option = alter.getAlterExpressions().get(0).getColDataTypeList().get(0) + .getColumnOptions().get(0); + assertEquals("it's", + ((StringValue) option.getDefaultExpression()).getNotExcapedValue()); + assertEquals("ALTER TABLE t ADD COLUMN s VARCHAR (10) DEFAULT " + literal, + alter.toString()); + } + } + + @Test + void testBackslashUnescapingIsLinear() { + int pairs = 1_000_000; + StringBuilder sb = new StringBuilder(2 * pairs + 2).append('\''); + for (int i = 0; i < pairs; i++) { + sb.append("\\'"); + } + StringValue instance = new StringValue(sb.append('\'').toString()) + .withEscaping(StringValue.Escaping.MYSQL_BACKSLASH); + + String result = assertTimeoutPreemptively(Duration.ofSeconds(2), + instance::getNotExcapedValue); + + assertEquals(pairs, result.length()); + } + @Test public void testPrefixes() { checkStringValue("E'test'", "test", "E");