From 30847b3826472ff0b6b3862ba4ff112b5f1a4eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Wed, 26 Aug 2026 14:41:14 +0800 Subject: [PATCH 1/3] feat(parser): concatenate adjacent string literals behind adjacentStringLiterals Three readings of two neighboring string literals: alias (MySQL, SQL Server, the current behavior), concatenated across a newline (the SQL standard and PostgreSQL), concatenated across any whitespace (GoogleSQL, Spark/Databricks). The mode lives in a String valued Feature like Feature.dialect, the merge is one guarded loop in PrimaryExpression using the token line numbers for the newline rule, producing a single merged StringValue. ANSI_SQL and POSTGRESQL presets carry NEWLINE. Implements item 3 of #2512. --- .../jsqlparser/parser/AbstractJSqlParser.java | 34 ++++++++-- .../sf/jsqlparser/parser/feature/Feature.java | 8 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 26 ++++++++ .../parser/CCJSqlParserUtilTest.java | 63 +++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java b/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java index 2cb803396..2a9bf0a99 100644 --- a/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java +++ b/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java @@ -24,24 +24,41 @@ public abstract class AbstractJSqlParser

{ protected boolean errorRecovery = false; protected List parseErrors = new ArrayList<>(); + public enum AdjacentStringLiterals { + OFF, NEWLINE, WHITESPACE + } + public enum Dialect { - ANSI_SQL, ORACLE, MYSQL(Feature.allowBackslashEscapeCharacter, + ANSI_SQL(AdjacentStringLiterals.NEWLINE), ORACLE, MYSQL(AdjacentStringLiterals.OFF, + Feature.allowBackslashEscapeCharacter, Feature.allowHashLineComments, - Feature.allowDoubleQuotedStrings), MARIADB(Feature.allowBackslashEscapeCharacter, + Feature.allowDoubleQuotedStrings), MARIADB(AdjacentStringLiterals.OFF, + Feature.allowBackslashEscapeCharacter, Feature.allowHashLineComments, - Feature.allowDoubleQuotedStrings), SQLSERVER( - Feature.allowSquareBracketQuotation), POSTGRESQL, H2, EXASOL; + Feature.allowDoubleQuotedStrings), SQLSERVER(AdjacentStringLiterals.OFF, + Feature.allowSquareBracketQuotation), POSTGRESQL( + AdjacentStringLiterals.NEWLINE), H2, EXASOL; private final Set lexerFeatures; + private final AdjacentStringLiterals adjacentStringLiterals; - Dialect(Feature... lexerFeatures) { + Dialect(AdjacentStringLiterals adjacentStringLiterals, Feature... lexerFeatures) { + this.adjacentStringLiterals = adjacentStringLiterals; this.lexerFeatures = lexerFeatures.length == 0 ? EnumSet.noneOf(Feature.class) : EnumSet.copyOf(Arrays.asList(lexerFeatures)); } + Dialect(Feature... lexerFeatures) { + this(AdjacentStringLiterals.OFF, lexerFeatures); + } + public Set getLexerFeatures() { return lexerFeatures; } + + public AdjacentStringLiterals getAdjacentStringLiterals() { + return adjacentStringLiterals; + } } public P withSquareBracketQuotation() { @@ -82,12 +99,19 @@ public P withTimeOut(long timeOutMillSeconds) { public P withDialect(Dialect dialect) { withFeature(Feature.dialect, dialect.name()); + if (dialect.getAdjacentStringLiterals() != AdjacentStringLiterals.OFF) { + withFeature(Feature.adjacentStringLiterals, dialect.getAdjacentStringLiterals().name()); + } for (Feature lexerFeature : dialect.getLexerFeatures()) { withFeature(lexerFeature, true); } return me(); } + public P withAdjacentStringLiterals(AdjacentStringLiterals adjacentStringLiterals) { + return withFeature(Feature.adjacentStringLiterals, adjacentStringLiterals.name()); + } + public P withAllowedNestingDepth(int allowedNestingDepth) { return withFeature(Feature.allowedNestingDepth, allowedNestingDepth); } diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index de5a492f2..d91829635 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -801,6 +801,14 @@ public enum Feature { */ allowDoubleQuotedStrings(false), + /** + * concatenates adjacent String Literals: NEWLINE when separated by whitespace with at least one + * newline (the SQL standard and PostgreSQL), WHITESPACE across any whitespace (GoogleSQL, + * Spark/Databricks); OFF by default, where the second literal stays an alias (MySQL, SQL + * Server) or fails (everywhere else) + */ + adjacentStringLiterals("OFF"), + /** * allows MySQL `#` line comments; disabled by default, where a lone `#` stays the binary * operator (#2507: PostgreSQL bitwise XOR / geometric intersection) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4533078c9..d379494a7 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1102,6 +1102,24 @@ public class CCJSqlParser extends AbstractJSqlParser { * This replaces an expensive syntactic LOOKAHEAD(NamedExpressionListExprFirst()) * that caused exponential backtracking with deeply nested expressions. */ + /** + * Semantic lookahead for the adjacent string literal concatenation in PrimaryExpression: + * true when Feature.adjacentStringLiterals is on and the next token is a string literal + * meeting the mode's separation rule (WHITESPACE: any, NEWLINE: a newline between the + * tokens, detected through the token line numbers). + */ + protected boolean isAdjacentStringConcat() { + String mode = getAsString(Feature.adjacentStringLiterals); + if (AdjacentStringLiterals.OFF.name().equals(mode)) { + return false; + } + if (getToken(1).kind != S_CHAR_LITERAL) { + return false; + } + return AdjacentStringLiterals.WHITESPACE.name().equals(mode) + || getToken(1).beginLine > getToken(0).endLine; + } + protected boolean isNamedExprListAhead() { int depth = 0; for (int i = 1; ; i++) { @@ -8319,6 +8337,7 @@ Expression PrimaryExpression() #PrimaryExpression: Expression timezoneRightExpr = null; Token token = null; Token sign = null; + Token adjacentToken = null; String tmp = ""; ColDataType type = null; boolean not = false; @@ -8419,6 +8438,13 @@ Expression PrimaryExpression() #PrimaryExpression: | LOOKAHEAD(2, {!interrupted}) (token= | token=) { retval = new BooleanValue(token.image); } | token= { retval = new StringValue(token.image); linkAST(retval,jjtThis); } + ( LOOKAHEAD({ isAdjacentStringConcat() }) + adjacentToken= + { + ((StringValue) retval) + .setValue(((StringValue) retval).getValue() + new StringValue(adjacentToken.image).getValue()); + } + )* | "{d" token= "}" { retval = new DateValue(token.image); } diff --git a/src/test/java/net/sf/jsqlparser/parser/CCJSqlParserUtilTest.java b/src/test/java/net/sf/jsqlparser/parser/CCJSqlParserUtilTest.java index 54c5e4402..12f0b1752 100644 --- a/src/test/java/net/sf/jsqlparser/parser/CCJSqlParserUtilTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/CCJSqlParserUtilTest.java @@ -655,4 +655,67 @@ public void testDoubleQuotedStringsPreset() throws Exception { p -> p.withDialect(AbstractJSqlParser.Dialect.SQLSERVER))).getSelectBody(); assertTrue(select.getSelectItems().get(0).getExpression() instanceof Column); } + + @Test + public void testAdjacentStringLiteralsNewline() throws Exception { + // default (off): the MySQL / SQL Server alias reading, unchanged + assertEquals("SELECT 'a' 'b'", CCJSqlParserUtil.parse("SELECT 'a' 'b'").toString()); + assertEquals("SELECT 'a' 'b'", CCJSqlParserUtil.parse("SELECT 'a'\n'b'").toString()); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT * FROM t WHERE x = 'a'\n'b'")); + // newline mode: the standard / Postgres reading, "Two string constants + // that are only separated by whitespace with at least one newline are + // concatenated" + PlainSelect select = (PlainSelect) ((Select) CCJSqlParserUtil.parse("SELECT 'a'\n'b'", + p -> p.withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.NEWLINE))) + .getSelectBody(); + Expression expression = select.getSelectItems().get(0).getExpression(); + assertTrue(expression instanceof StringValue); + assertEquals("ab", ((StringValue) expression).getValue()); + assertEquals("SELECT 'ab'", select.toString()); + // same line keeps the alias reading under newline mode + assertEquals("SELECT 'a' 'b'", + CCJSqlParserUtil.parse("SELECT 'a' 'b'", + p -> p.withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.NEWLINE)) + .toString()); + // expression positions concatenate too + assertEquals("SELECT * FROM t WHERE x = 'ab'", + CCJSqlParserUtil.parse("SELECT * FROM t WHERE x = 'a'\n'b'", + p -> p.withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.NEWLINE)) + .toString()); + // three parts merge into one literal + assertEquals("SELECT 'abc'", + CCJSqlParserUtil.parse("SELECT 'a'\n'b'\n'c'", + p -> p.withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.NEWLINE)) + .toString()); + // the ANSI SQL and Postgres presets carry it, the MySQL preset does not + for (AbstractJSqlParser.Dialect dialect : new AbstractJSqlParser.Dialect[] { + AbstractJSqlParser.Dialect.ANSI_SQL, AbstractJSqlParser.Dialect.POSTGRESQL}) { + assertEquals("SELECT 'ab'", CCJSqlParserUtil.parse("SELECT 'a'\n'b'", + p -> p.withDialect(dialect)).toString()); + } + assertEquals("SELECT 'a' 'b'", CCJSqlParserUtil.parse("SELECT 'a'\n'b'", + p -> p.withDialect(AbstractJSqlParser.Dialect.MYSQL)).toString()); + } + + @Test + public void testAdjacentStringLiteralsWhitespace() throws Exception { + // whitespace mode: the GoogleSQL / Spark reading, same line + // concatenates (their literal chunking) + assertEquals("SELECT 'ab'", + CCJSqlParserUtil.parse("SELECT 'a' 'b'", + p -> p.withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.WHITESPACE)) + .toString()); + // combined with allowDoubleQuotedStrings: the BigQuery chunking shape + assertEquals("SELECT \"12\"", + CCJSqlParserUtil.parse("SELECT \"1\" \"2\"", + p -> p.withDoubleQuotedStrings(true).withAdjacentStringLiterals( + AbstractJSqlParser.AdjacentStringLiterals.WHITESPACE)) + .toString()); + } } From d95c743d102012432834caed07e799f0cdd0f646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Wed, 26 Aug 2026 14:46:29 +0800 Subject: [PATCH 2/3] docs(site): document adjacentStringLiterals in the parser features section --- src/site/sphinx/usage.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 4a9696b17..8c4d6fc8f 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -279,7 +279,7 @@ Define the Parser Features JSQLParser interprets Squared Brackets ``[..]`` as Arrays, which does not work with MS SQL Server and T-SQL. Please use the Parser Features to instruct JSQLParser to read Squared Brackets as Quotes instead. -JSQLParser allows for standard compliant Single Quote ``'..`` Escaping. Additional Back-slash ``\..`` Escaping needs to be activated by setting the ``BackSlashEscapeCharacter`` parser feature. JSQLParser reads Double Quotes ``".."`` as quoted identifiers (ANSI SQL); reading them as String Literals (BigQuery, Spark/Databricks, MySQL default sql_mode) needs the ``DoubleQuotedStrings`` parser feature. +JSQLParser allows for standard compliant Single Quote ``'..`` Escaping. Additional Back-slash ``\..`` Escaping needs to be activated by setting the ``BackSlashEscapeCharacter`` parser feature. JSQLParser reads Double Quotes ``".."`` as quoted identifiers (ANSI SQL); reading them as String Literals (BigQuery, Spark/Databricks, MySQL default sql_mode) needs the ``DoubleQuotedStrings`` parser feature. Adjacent String Literals concatenate optionally: only across a newline (``NEWLINE``, the SQL standard and PostgreSQL) or across any whitespace (``WHITESPACE``, GoogleSQL and Spark/Databricks). Additionally there are Features to control the Parser's effort at the cost of the performance. @@ -319,7 +319,7 @@ Additionally there are Features to control the Parser's effort at the cost of th .withBackslashEscapeCharacter(true) ); -Instead of turning the individual Parser Features on one by one, a ``Dialect`` preset selects the features of that database dialect: ``withDialect(Dialect.MYSQL)`` turns on ``withBackslashEscapeCharacter``, ``withHashLineComments`` and ``withDoubleQuotedStrings`` (MySQL and MariaDB syntax, the latter for the default sql_mode), ``withDialect(Dialect.SQLSERVER)`` turns on ``withSquareBracketQuotation``. Features set explicitly after the dialect preset win over the preset. +Instead of turning the individual Parser Features on one by one, a ``Dialect`` preset selects the features of that database dialect: ``withDialect(Dialect.MYSQL)`` turns on ``withBackslashEscapeCharacter``, ``withHashLineComments`` and ``withDoubleQuotedStrings`` (MySQL and MariaDB syntax, the latter for the default sql_mode), ``withDialect(Dialect.SQLSERVER)`` turns on ``withSquareBracketQuotation``. ``withDialect(Dialect.POSTGRESQL)`` and ``withDialect(Dialect.ANSI_SQL)`` turn on the newline rule for adjacent String Literals. Features set explicitly after the dialect preset win over the preset. .. code-block:: java From 9f58f3d0a4823bc2ecfdd05e83cfa73f03d1e209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Wed, 26 Aug 2026 15:21:04 +0800 Subject: [PATCH 3/3] hardening: null-safe mode check in isAdjacentStringConcat The FeatureConfiguration seeds every configurable Feature with its declared default, so the mode is never null today; the guard keeps the predicate locally correct regardless. --- src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index d379494a7..6262345fe 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1110,7 +1110,7 @@ public class CCJSqlParser extends AbstractJSqlParser { */ protected boolean isAdjacentStringConcat() { String mode = getAsString(Feature.adjacentStringLiterals); - if (AdjacentStringLiterals.OFF.name().equals(mode)) { + if (mode == null || AdjacentStringLiterals.OFF.name().equals(mode)) { return false; } if (getToken(1).kind != S_CHAR_LITERAL) {