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");